@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.js
CHANGED
|
@@ -5,11 +5,14 @@ import { PredicateSchema } from '../events/predicate.js';
|
|
|
5
5
|
import { isToonable, resultToToon } from '@reticlehq/core';
|
|
6
6
|
import { TOOLS } from '../tools/tools.js';
|
|
7
7
|
import { filterTools, describeToolSurface, TOOL_SURFACE, } from '../tools/tool-surface.js';
|
|
8
|
+
import { ReticleTool } from '../tools/tool-names.js';
|
|
9
|
+
import { SHARED_PARAM_SHORT } from './shared-params.js';
|
|
8
10
|
import { buildDynamicTools } from '../tools/dynamic-tools.js';
|
|
9
11
|
import { runTool, SESSION_BOUND_TOOLS } from '../tools/invoke-tool.js';
|
|
10
12
|
import { sessionEnvelopeShape } from '../tools/tool-kit.js';
|
|
11
13
|
import { buildErrorPayload } from '../tools/error-recovery.js';
|
|
12
14
|
import { resultIsError } from './mcp-is-error.js';
|
|
15
|
+
import { buildServerInstructions } from './server-instructions.js';
|
|
13
16
|
import { unadvertisedToolHelp } from '../tools/unadvertised-help.js';
|
|
14
17
|
/** The JSON-RPC method the SDK registers its tool dispatcher under. */
|
|
15
18
|
const CALL_TOOL_METHOD = 'tools/call';
|
|
@@ -31,27 +34,6 @@ export function withSessionEnvelope(name, outputSchema) {
|
|
|
31
34
|
return { ...sessionEnvelopeShape, ...outputSchema };
|
|
32
35
|
}
|
|
33
36
|
const SERVER_INFO = { name: MCP_SERVER_NAME, version: SERVER_VERSION };
|
|
34
|
-
/**
|
|
35
|
-
* What every agent is told at `initialize`, before it has called a single tool.
|
|
36
|
-
*
|
|
37
|
-
* This is the only channel that reaches an agent with NO dependency on the project: no CLAUDE.md, no
|
|
38
|
-
* AGENTS.md, no pasted skill, no successful `reticle init`. An agent whose setup half-worked has read
|
|
39
|
-
* none of those files and is precisely the agent whose report we need — so the standing instruction
|
|
40
|
-
* to send feedback has to live here, where it arrives with the connection itself.
|
|
41
|
-
*
|
|
42
|
-
* Kept to a handful of lines: it is prepended to every session's context, so each sentence is paid
|
|
43
|
-
* for on every turn, forever.
|
|
44
|
-
*/
|
|
45
|
-
const SERVER_INSTRUCTIONS = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
|
|
46
|
-
|
|
47
|
-
Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. A verdict of verified:"unknown" is not a pass: it means Reticle drove the app and could not tell what happened. Report it as unknown.
|
|
48
|
-
|
|
49
|
-
Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
|
|
50
|
-
- Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
|
|
51
|
-
- You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
|
|
52
|
-
- The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
|
|
53
|
-
|
|
54
|
-
Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
|
|
55
37
|
/** First sentence of a description (purpose only) for lean profiles — keeps per-turn def cost
|
|
56
38
|
* down. Cuts at the first sentence-ending period or newline; falls back to a 160-char cap. The
|
|
57
39
|
* first clause retains the essentials (enum hints like "role | text | label" live there). */
|
|
@@ -144,8 +126,10 @@ const PREDICATE_KINDS = 'Predicate object: { kind, ...fields }. kind is one of e
|
|
|
144
126
|
* paying for six.
|
|
145
127
|
*/
|
|
146
128
|
const PREDICATE_FIELD_GRAMMAR_HINT = ' Bug-catching options: net.count (exact request count — catches double-submit), ' +
|
|
147
|
-
'
|
|
148
|
-
'
|
|
129
|
+
'net.bodyContains (a substring of what the SERVER answered — catches a UI that echoes the input ' +
|
|
130
|
+
'it sent instead of the result it got back), console.absent:true (action completed with a CLEAN ' +
|
|
131
|
+
'console), absent:true on element/text (it should be gone). Call reticle_tools for the full field ' +
|
|
132
|
+
'grammar of a kind.';
|
|
149
133
|
const COMPACT_PREDICATE_DESCRIPTION = `${PREDICATE_KINDS}${PREDICATE_FIELD_GRAMMAR_HINT}`;
|
|
150
134
|
/**
|
|
151
135
|
* Lean copy of a tool's zod input shape for lean profiles: each parameter's description is
|
|
@@ -170,6 +154,16 @@ function leanZodShape(shape, predicateAnchor) {
|
|
|
170
154
|
out[key] = schema.isOptional() ? compact.optional() : compact;
|
|
171
155
|
continue;
|
|
172
156
|
}
|
|
157
|
+
// Shared boilerplate is documented once in the initialize instructions instead of on every
|
|
158
|
+
// tool, every turn. `sessionId` alone was 2,076 bytes of identical text across 16 tools.
|
|
159
|
+
const shared = SHARED_PARAM_SHORT[key];
|
|
160
|
+
if (shared !== undefined) {
|
|
161
|
+
// `.describe` returns the same schema with new prose, so optionality carries over. Re-wrapping
|
|
162
|
+
// in `.optional()` would nest one inside another and change the parameter's TYPE, which is a
|
|
163
|
+
// thing this lean path is meant to do for predicates and nothing else.
|
|
164
|
+
out[key] = schema.describe(shared);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
173
167
|
const desc = schema.description;
|
|
174
168
|
out[key] = 'string' === typeof desc ? schema.describe(firstSentence(desc)) : schema;
|
|
175
169
|
}
|
|
@@ -228,10 +222,25 @@ export function advertisedConfig(tool, advertised, profile) {
|
|
|
228
222
|
// sentence and the agent would have nothing left to learn the surface from. Measured when it
|
|
229
223
|
// happened: dynamic's tools/list fell 1,543 -> 849 bytes, which looks like a saving and is a
|
|
230
224
|
// capability loss.
|
|
231
|
-
|
|
225
|
+
//
|
|
226
|
+
// The two meta-tools are exempt, on every surface. Their descriptions are not a description OF a
|
|
227
|
+
// capability, they ARE the capability: the only place an agent is ever told that the tools it can
|
|
228
|
+
// see are not all the tools there are, and how to reach the rest. Trimmed to a first sentence,
|
|
229
|
+
// `reticle_tools` advertises as "Discover Reticle tools on demand." — which names no way to list
|
|
230
|
+
// anything, so a well-behaved agent has no reason to call it and the entire cold tail goes dark.
|
|
231
|
+
// The comment above already caught this for the retired `dynamic` profile; `default` is now both
|
|
232
|
+
// the lean surface and the one carrying the meta-tools, so the exemption follows it.
|
|
233
|
+
const isMetaTool = tool.name === ReticleTool.TOOLS || tool.name === ReticleTool.RUN;
|
|
234
|
+
// `verify` is lean for the same reason `default` is, and more so: it exists to make a verdict
|
|
235
|
+
// cheap, and it was serving FULL descriptions plus outputSchema because the check named only the
|
|
236
|
+
// default. That put 8,207 bytes on the wire for three tools.
|
|
237
|
+
const lean = profile === TOOL_SURFACE.DEFAULT || profile === TOOL_SURFACE.VERIFY;
|
|
238
|
+
const terse = lean && !isMetaTool;
|
|
232
239
|
// The first advertised tool carrying a predicate spells the grammar out; the rest point at it.
|
|
233
240
|
const anchor = advertised.find((t) => Object.values(t.inputSchema).some((schema) => isPredicateParam(schema)))?.name;
|
|
234
|
-
|
|
241
|
+
// Output schemas are dropped on every lean surface, meta-tools included: they are the single
|
|
242
|
+
// largest component and nothing on a lean surface validates against them.
|
|
243
|
+
const outputSchema = lean ? undefined : withSessionEnvelope(tool.name, tool.outputSchema);
|
|
235
244
|
return {
|
|
236
245
|
description: withExample(terse ? firstSentence(tool.description) : tool.description, tool.example),
|
|
237
246
|
inputSchema: terse
|
|
@@ -346,22 +355,49 @@ function toolNameOf(request) {
|
|
|
346
355
|
const name = params.name;
|
|
347
356
|
return 'string' === typeof name ? name : undefined;
|
|
348
357
|
}
|
|
349
|
-
export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT
|
|
358
|
+
export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT,
|
|
359
|
+
/**
|
|
360
|
+
* Has an app ever connected for this project? Decides what the instructions LEAD with.
|
|
361
|
+
*
|
|
362
|
+
* Passed in rather than read here, so construction stays free of disk I/O and the decision is
|
|
363
|
+
* testable without a state directory. Defaulting to `false` is the deliberate safe side: an agent
|
|
364
|
+
* told to instrument a project that is already wired loses a paragraph, while an agent not told
|
|
365
|
+
* loses the entire install — and the second is what the field overwhelmingly shows.
|
|
366
|
+
*/
|
|
367
|
+
previouslyConnected = false) {
|
|
350
368
|
const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
|
|
351
369
|
// Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
|
|
352
370
|
// different products from inside an agent's context, so outcomes are only comparable when the
|
|
353
371
|
// session says which one it saw.
|
|
354
372
|
getSessionMetrics().recordSurface(profile);
|
|
355
|
-
const server = new McpServer(SERVER_INFO, {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
373
|
+
const server = new McpServer(SERVER_INFO, {
|
|
374
|
+
instructions: buildServerInstructions({ previouslyConnected }),
|
|
375
|
+
});
|
|
376
|
+
/**
|
|
377
|
+
* Record WHICH agent attached, at the handshake, for every session.
|
|
378
|
+
*
|
|
379
|
+
* `clients` and `clientVersions` were populated for almost nobody, and the reason was not that
|
|
380
|
+
* clients withhold `clientInfo` — they send it, and we read it. It was that the ONLY thing that
|
|
381
|
+
* ever called `recordClient` was the feedback hook below, which runs when a report is being built.
|
|
382
|
+
* So the field was really measuring "sessions that filed feedback", and a product whose users are
|
|
383
|
+
* agents could not say which agent, on which build, any of its numbers came from.
|
|
384
|
+
*
|
|
385
|
+
* `oninitialized` is the moment `clientInfo` first exists — reading it at construction time gets
|
|
386
|
+
* `undefined`, which is what made a lazy hook look like the only option.
|
|
387
|
+
*/
|
|
388
|
+
server.server.oninitialized = () => {
|
|
389
|
+
const info = server.server.getClientVersion();
|
|
390
|
+
if (info?.name !== undefined)
|
|
391
|
+
getSessionMetrics().recordClient(info.name, info.version);
|
|
392
|
+
};
|
|
393
|
+
// Which agent is on the other end, for a feedback report. Registered as a lazy hook rather than
|
|
394
|
+
// read here: the handshake has not happened yet at construction time, and a report filed twenty
|
|
395
|
+
// tool calls later is exactly when we want the answer.
|
|
359
396
|
setMcpClientNameHook(() => {
|
|
360
397
|
const info = server.server.getClientVersion();
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// regression in agent behaviour could never be attributed to a client build.
|
|
398
|
+
// Still records, because a client that skipped the handshake path above (a replayed initialize
|
|
399
|
+
// through the proxy, a transport that reconnects) is a client we would otherwise lose entirely.
|
|
400
|
+
// `recordClient` is idempotent per name.
|
|
365
401
|
if (info?.name !== undefined)
|
|
366
402
|
getSessionMetrics().recordClient(info.name, info.version);
|
|
367
403
|
return info === undefined ? undefined : { name: info.name, version: info.version };
|
|
@@ -1,3 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol's own way to say "the tool list you have is out of date, fetch it again".
|
|
3
|
+
*
|
|
4
|
+
* Load-bearing here rather than a nicety. When we answer `initialize` ourselves the catalog we serve
|
|
5
|
+
* is one we made up, and it is usually empty. Nothing else in the protocol ever corrects that: a
|
|
6
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
7
|
+
* NEXT client request, which a client holding no tools never makes. Without this the session is
|
|
8
|
+
* connected, initialized and toolless until a human notices.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TOOLS_CHANGED_NOTIFICATION = "notifications/tools/list_changed";
|
|
1
11
|
/** True when this queued client line is the handshake we answered ourselves. */
|
|
2
12
|
export declare function isHandshakeLine(line: string): boolean;
|
|
3
|
-
export declare function localInitializeResponse(line: string
|
|
13
|
+
export declare function localInitializeResponse(line: string,
|
|
14
|
+
/**
|
|
15
|
+
* What the daemon would have advertised, so the client is not left with none.
|
|
16
|
+
*
|
|
17
|
+
* A client reads `instructions` ONCE, at initialize, and this response is sent precisely when no
|
|
18
|
+
* daemon is up to send its own — which is the first run of a fresh install. So the block telling
|
|
19
|
+
* someone that having these tools is not the same as being set up was permanently absent for the
|
|
20
|
+
* exact population it addresses, and the daemon's later correct instructions arrive at a client
|
|
21
|
+
* that will never read the field again.
|
|
22
|
+
*
|
|
23
|
+
* Empty means "nothing to say", and the field is then omitted rather than sent blank.
|
|
24
|
+
*/
|
|
25
|
+
instructions?: string): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The line that tells a client its tool list is stale, or null when there is nothing to correct.
|
|
28
|
+
*
|
|
29
|
+
* When a daemon arrives after we answered the handshake ourselves, the client is holding a tool list
|
|
30
|
+
* we invented, and on a cold start that list is empty. Nothing else in this protocol corrects it: a
|
|
31
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
32
|
+
* NEXT client request, which a client holding no tools never makes. Connected, initialized and
|
|
33
|
+
* toolless for the rest of the session, with a human required to notice and reconnect by hand.
|
|
34
|
+
*
|
|
35
|
+
* Sent only when the catalog was ours. A client that completed a real handshake with the daemon
|
|
36
|
+
* already has the true list, and telling it to refetch would be a round trip for nothing.
|
|
37
|
+
*
|
|
38
|
+
* Lives here rather than in the proxy because it belongs to the locally-answered handshake, which is
|
|
39
|
+
* this module's subject.
|
|
40
|
+
*
|
|
41
|
+
* Worth knowing before relying on it: this is a best-effort correction, not the mechanism. Research
|
|
42
|
+
* into the four clients we target found that only some honour `notifications/tools/list_changed` at
|
|
43
|
+
* all, so the catalog still has to be right in the FIRST answer for the rest. This closes the gap
|
|
44
|
+
* where it is honoured and costs one line where it is not.
|
|
45
|
+
*/
|
|
46
|
+
export declare function toolsChangedNotification(catalogWasLocal: boolean): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The largest single JSON-RPC line the proxy will accumulate from a client.
|
|
49
|
+
*
|
|
50
|
+
* Generous — a tool call with a big argument payload is legitimate — but finite, because without a
|
|
51
|
+
* ceiling the buffer grows to whatever is sent and the process is one `cat bigfile` away from an
|
|
52
|
+
* out-of-memory exit that would take the agent's whole tool surface with it.
|
|
53
|
+
*/
|
|
54
|
+
export declare const MAX_STDIN_LINE_BYTES: number;
|
|
55
|
+
export interface DrainedLines {
|
|
56
|
+
/** Complete lines, in order. Empty when the chunk carried no newline. */
|
|
57
|
+
lines: string[];
|
|
58
|
+
/** The trailing partial line, carried into the next chunk. */
|
|
59
|
+
rest: string;
|
|
60
|
+
/** A single line exceeded the cap and was discarded rather than accumulated. */
|
|
61
|
+
overflowed: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Mid-discard: an oversized line was dropped and the REST of it is still arriving.
|
|
64
|
+
*
|
|
65
|
+
* Carried back in on the next call, and it is what makes dropping safe rather than merely tidy.
|
|
66
|
+
* Without it the tail of the discarded line arrives looking like a complete line and is forwarded
|
|
67
|
+
* as a JSON-RPC message — a fragment of something nobody sent, failing to parse, on a link where
|
|
68
|
+
* the client is waiting for answers to real requests.
|
|
69
|
+
*/
|
|
70
|
+
discarding: boolean;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Append a chunk and split off whatever complete lines it produced.
|
|
74
|
+
*
|
|
75
|
+
* The reason this is not `(buffer + chunk).split('\n')` inline: that re-scans the WHOLE accumulated
|
|
76
|
+
* buffer on every chunk, so a single large line costs O(n²) in the size of the line. Measured
|
|
77
|
+
* against the real proxy, a 32 MB line took ~8s and a 50 MB line ~35s — with the event loop pinned
|
|
78
|
+
* throughout, so every other tool call on that link was frozen with no response and no error. The
|
|
79
|
+
* client sees the whole MCP surface hang.
|
|
80
|
+
*
|
|
81
|
+
* A chunk with no newline in it cannot complete a line, so there is nothing to split and the cost is
|
|
82
|
+
* the concatenation alone. Only a chunk that actually carries a newline pays for a scan.
|
|
83
|
+
*/
|
|
84
|
+
export declare function drainLines(buffer: string, chunk: string, maxLineBytes?: number, discarding?: boolean): DrainedLines;
|
|
@@ -18,6 +18,16 @@ import { MCP_SERVER_NAME } from '../init/mcp.js';
|
|
|
18
18
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
19
19
|
/** The version we answer with if the client proposed none. */
|
|
20
20
|
const FALLBACK_PROTOCOL_VERSION = '2024-11-05';
|
|
21
|
+
/**
|
|
22
|
+
* The protocol's own way to say "the tool list you have is out of date, fetch it again".
|
|
23
|
+
*
|
|
24
|
+
* Load-bearing here rather than a nicety. When we answer `initialize` ourselves the catalog we serve
|
|
25
|
+
* is one we made up, and it is usually empty. Nothing else in the protocol ever corrects that: a
|
|
26
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
27
|
+
* NEXT client request, which a client holding no tools never makes. Without this the session is
|
|
28
|
+
* connected, initialized and toolless until a human notices.
|
|
29
|
+
*/
|
|
30
|
+
export const TOOLS_CHANGED_NOTIFICATION = 'notifications/tools/list_changed';
|
|
21
31
|
function parseLine(line) {
|
|
22
32
|
try {
|
|
23
33
|
const parsed = JSON.parse(line);
|
|
@@ -32,7 +42,19 @@ export function isHandshakeLine(line) {
|
|
|
32
42
|
const msg = parseLine(line);
|
|
33
43
|
return 'initialize' === msg?.method || 'notifications/initialized' === msg?.method;
|
|
34
44
|
}
|
|
35
|
-
export function localInitializeResponse(line
|
|
45
|
+
export function localInitializeResponse(line,
|
|
46
|
+
/**
|
|
47
|
+
* What the daemon would have advertised, so the client is not left with none.
|
|
48
|
+
*
|
|
49
|
+
* A client reads `instructions` ONCE, at initialize, and this response is sent precisely when no
|
|
50
|
+
* daemon is up to send its own — which is the first run of a fresh install. So the block telling
|
|
51
|
+
* someone that having these tools is not the same as being set up was permanently absent for the
|
|
52
|
+
* exact population it addresses, and the daemon's later correct instructions arrive at a client
|
|
53
|
+
* that will never read the field again.
|
|
54
|
+
*
|
|
55
|
+
* Empty means "nothing to say", and the field is then omitted rather than sent blank.
|
|
56
|
+
*/
|
|
57
|
+
instructions = '') {
|
|
36
58
|
const msg = parseLine(line);
|
|
37
59
|
if (null === msg || msg.method !== 'initialize')
|
|
38
60
|
return null;
|
|
@@ -47,8 +69,93 @@ export function localInitializeResponse(line) {
|
|
|
47
69
|
// Echo what the client asked for: answering with a version it did not offer is its own
|
|
48
70
|
// handshake failure.
|
|
49
71
|
protocolVersion: 'string' === typeof proposed ? proposed : FALLBACK_PROTOCOL_VERSION,
|
|
50
|
-
|
|
72
|
+
// `listChanged` is not decoration: a client that was not told the list can change has no
|
|
73
|
+
// reason to honour the notification we send when the daemon finally arrives, and declaring a
|
|
74
|
+
// capability we then rely on is the difference between a fix and a message into the void.
|
|
75
|
+
capabilities: { tools: { listChanged: true } },
|
|
51
76
|
serverInfo: { name: MCP_SERVER_NAME, version: SERVER_VERSION },
|
|
77
|
+
...('' === instructions ? {} : { instructions }),
|
|
52
78
|
},
|
|
53
79
|
});
|
|
54
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* The line that tells a client its tool list is stale, or null when there is nothing to correct.
|
|
83
|
+
*
|
|
84
|
+
* When a daemon arrives after we answered the handshake ourselves, the client is holding a tool list
|
|
85
|
+
* we invented, and on a cold start that list is empty. Nothing else in this protocol corrects it: a
|
|
86
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
87
|
+
* NEXT client request, which a client holding no tools never makes. Connected, initialized and
|
|
88
|
+
* toolless for the rest of the session, with a human required to notice and reconnect by hand.
|
|
89
|
+
*
|
|
90
|
+
* Sent only when the catalog was ours. A client that completed a real handshake with the daemon
|
|
91
|
+
* already has the true list, and telling it to refetch would be a round trip for nothing.
|
|
92
|
+
*
|
|
93
|
+
* Lives here rather than in the proxy because it belongs to the locally-answered handshake, which is
|
|
94
|
+
* this module's subject.
|
|
95
|
+
*
|
|
96
|
+
* Worth knowing before relying on it: this is a best-effort correction, not the mechanism. Research
|
|
97
|
+
* into the four clients we target found that only some honour `notifications/tools/list_changed` at
|
|
98
|
+
* all, so the catalog still has to be right in the FIRST answer for the rest. This closes the gap
|
|
99
|
+
* where it is honoured and costs one line where it is not.
|
|
100
|
+
*/
|
|
101
|
+
export function toolsChangedNotification(catalogWasLocal) {
|
|
102
|
+
if (!catalogWasLocal)
|
|
103
|
+
return null;
|
|
104
|
+
return JSON.stringify({ jsonrpc: '2.0', method: TOOLS_CHANGED_NOTIFICATION });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The largest single JSON-RPC line the proxy will accumulate from a client.
|
|
108
|
+
*
|
|
109
|
+
* Generous — a tool call with a big argument payload is legitimate — but finite, because without a
|
|
110
|
+
* ceiling the buffer grows to whatever is sent and the process is one `cat bigfile` away from an
|
|
111
|
+
* out-of-memory exit that would take the agent's whole tool surface with it.
|
|
112
|
+
*/
|
|
113
|
+
export const MAX_STDIN_LINE_BYTES = 16 * 1024 * 1024;
|
|
114
|
+
/**
|
|
115
|
+
* Append a chunk and split off whatever complete lines it produced.
|
|
116
|
+
*
|
|
117
|
+
* The reason this is not `(buffer + chunk).split('\n')` inline: that re-scans the WHOLE accumulated
|
|
118
|
+
* buffer on every chunk, so a single large line costs O(n²) in the size of the line. Measured
|
|
119
|
+
* against the real proxy, a 32 MB line took ~8s and a 50 MB line ~35s — with the event loop pinned
|
|
120
|
+
* throughout, so every other tool call on that link was frozen with no response and no error. The
|
|
121
|
+
* client sees the whole MCP surface hang.
|
|
122
|
+
*
|
|
123
|
+
* A chunk with no newline in it cannot complete a line, so there is nothing to split and the cost is
|
|
124
|
+
* the concatenation alone. Only a chunk that actually carries a newline pays for a scan.
|
|
125
|
+
*/
|
|
126
|
+
export function drainLines(buffer, chunk, maxLineBytes = MAX_STDIN_LINE_BYTES, discarding = false) {
|
|
127
|
+
// Finish discarding first. Everything up to and including the next newline belongs to the line
|
|
128
|
+
// that was already dropped, and only what follows it is a message again.
|
|
129
|
+
if (discarding) {
|
|
130
|
+
const cut = chunk.indexOf('\n');
|
|
131
|
+
if (-1 === cut)
|
|
132
|
+
return { lines: [], rest: '', overflowed: true, discarding: true };
|
|
133
|
+
return {
|
|
134
|
+
...drainLines('', chunk.slice(cut + 1), maxLineBytes),
|
|
135
|
+
overflowed: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const next = buffer + chunk;
|
|
139
|
+
if (!chunk.includes('\n')) {
|
|
140
|
+
// Nothing can be complete yet. Cap here rather than on the split path: this is the branch an
|
|
141
|
+
// oversized line stays on, so it is the only one that can grow without bound. Dropping the
|
|
142
|
+
// buffer is half the job — the rest of that line is still coming, hence `discarding`.
|
|
143
|
+
if (next.length > maxLineBytes) {
|
|
144
|
+
return { lines: [], rest: '', overflowed: true, discarding: true };
|
|
145
|
+
}
|
|
146
|
+
return { lines: [], rest: next, overflowed: false, discarding: false };
|
|
147
|
+
}
|
|
148
|
+
const parts = next.split('\n');
|
|
149
|
+
const rest = parts.pop() ?? '';
|
|
150
|
+
// A completed line over the cap is dropped, not truncated: half a JSON-RPC message is not a
|
|
151
|
+
// message, and forwarding a prefix would be a parse error attributed to the client. Its newline
|
|
152
|
+
// has already arrived, so there is nothing left to swallow for it.
|
|
153
|
+
const oversizedLine = parts.some((line) => line.length > maxLineBytes);
|
|
154
|
+
const restOversized = rest.length > maxLineBytes;
|
|
155
|
+
return {
|
|
156
|
+
lines: oversizedLine ? parts.filter((line) => line.length <= maxLineBytes) : parts,
|
|
157
|
+
rest: restOversized ? '' : rest,
|
|
158
|
+
overflowed: oversizedLine || restOversized,
|
|
159
|
+
discarding: restOversized,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* that crashed or was stopped used to take the agent's whole Reticle surface with it. Together they
|
|
8
8
|
* loop: the daemon exits as useless, the proxy immediately brings back a daemon that is equally
|
|
9
9
|
* useless, which exits, forever. Measured with a 4s grace: four processes in 200 seconds. At the
|
|
10
|
-
* real 300s grace that is a new process every five minutes for the
|
|
10
|
+
* real 300s grace that is a new process every five minutes for the many installs that never call a
|
|
11
11
|
* tool, for as long as the editor is open.
|
|
12
12
|
*
|
|
13
13
|
* The rule that resolves it: a dropped stream is not demand. Reattach to a daemon that is already
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* reach it — and a guard that re-implements the decision instead of calling it is insensitive to the
|
|
18
18
|
* thing it claims to guard.
|
|
19
19
|
*/
|
|
20
|
+
import { PortPresence } from '../daemon/port-presence.js';
|
|
20
21
|
/** What to do when the SSE stream drops. */
|
|
21
22
|
export declare const OnDrop: {
|
|
22
23
|
/** A daemon is listening — reattach to it. */
|
|
@@ -28,8 +29,16 @@ export type OnDrop = (typeof OnDrop)[keyof typeof OnDrop];
|
|
|
28
29
|
/**
|
|
29
30
|
* A stream drop only ever justifies reattaching, never spawning. This is the whole fix: the proxy
|
|
30
31
|
* used to spawn here, which turned a daemon's own idle shutdown into a permanent respawn loop.
|
|
32
|
+
*
|
|
33
|
+
* It takes the PRESENCE, not a boolean. It used to take `daemonListening`, filled from a bare TCP
|
|
34
|
+
* connect — a probe that cannot tell a Reticle daemon from anything else that accepts a socket. A
|
|
35
|
+
* stranger on the bridge port therefore read as "a daemon is there", and since a stranger answers
|
|
36
|
+
* and closes, every reattach dropped and reattached again: measured at nineteen reconnects in
|
|
37
|
+
* forty-eight seconds, each logged as `reticle_mcp_proxy_reconnected`, forever. `probePresence`
|
|
38
|
+
* already asks the second question and every human-facing command already uses it; this was the last
|
|
39
|
+
* decision point still guessing from the first.
|
|
31
40
|
*/
|
|
32
|
-
export declare function onStreamDrop(
|
|
41
|
+
export declare function onStreamDrop(presence: PortPresence): OnDrop;
|
|
33
42
|
/**
|
|
34
43
|
* What to do when the reconnect budget runs out. Always DORMANT — never exit.
|
|
35
44
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* that crashed or was stopped used to take the agent's whole Reticle surface with it. Together they
|
|
8
8
|
* loop: the daemon exits as useless, the proxy immediately brings back a daemon that is equally
|
|
9
9
|
* useless, which exits, forever. Measured with a 4s grace: four processes in 200 seconds. At the
|
|
10
|
-
* real 300s grace that is a new process every five minutes for the
|
|
10
|
+
* real 300s grace that is a new process every five minutes for the many installs that never call a
|
|
11
11
|
* tool, for as long as the editor is open.
|
|
12
12
|
*
|
|
13
13
|
* The rule that resolves it: a dropped stream is not demand. Reattach to a daemon that is already
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* reach it — and a guard that re-implements the decision instead of calling it is insensitive to the
|
|
18
18
|
* thing it claims to guard.
|
|
19
19
|
*/
|
|
20
|
+
import { PortPresence } from '../daemon/port-presence.js';
|
|
20
21
|
/** What to do when the SSE stream drops. */
|
|
21
22
|
export const OnDrop = {
|
|
22
23
|
/** A daemon is listening — reattach to it. */
|
|
@@ -27,9 +28,17 @@ export const OnDrop = {
|
|
|
27
28
|
/**
|
|
28
29
|
* A stream drop only ever justifies reattaching, never spawning. This is the whole fix: the proxy
|
|
29
30
|
* used to spawn here, which turned a daemon's own idle shutdown into a permanent respawn loop.
|
|
31
|
+
*
|
|
32
|
+
* It takes the PRESENCE, not a boolean. It used to take `daemonListening`, filled from a bare TCP
|
|
33
|
+
* connect — a probe that cannot tell a Reticle daemon from anything else that accepts a socket. A
|
|
34
|
+
* stranger on the bridge port therefore read as "a daemon is there", and since a stranger answers
|
|
35
|
+
* and closes, every reattach dropped and reattached again: measured at nineteen reconnects in
|
|
36
|
+
* forty-eight seconds, each logged as `reticle_mcp_proxy_reconnected`, forever. `probePresence`
|
|
37
|
+
* already asks the second question and every human-facing command already uses it; this was the last
|
|
38
|
+
* decision point still guessing from the first.
|
|
30
39
|
*/
|
|
31
|
-
export function onStreamDrop(
|
|
32
|
-
return
|
|
40
|
+
export function onStreamDrop(presence) {
|
|
41
|
+
return PortPresence.DAEMON === presence ? OnDrop.REATTACH : OnDrop.DORMANT;
|
|
33
42
|
}
|
|
34
43
|
/**
|
|
35
44
|
* What to do when the reconnect budget runs out. Always DORMANT — never exit.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP proxy's own log file: where it lives, what bounds it, and how it is written.
|
|
3
|
+
*
|
|
4
|
+
* Split out of mcp-proxy.ts, which is the transport. This is housekeeping for a file, and the two
|
|
5
|
+
* share nothing but a port number — the proxy calls `proxyLog`, and everything else here exists to
|
|
6
|
+
* keep that file from becoming the largest thing on the user's disk.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* How much the proxy may append between size checks.
|
|
10
|
+
*
|
|
11
|
+
* Statting the file on every line would be a syscall per log line on a process that logs from a
|
|
12
|
+
* reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
|
|
13
|
+
* the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PROXY_LOG_CHECK_BYTES: number;
|
|
16
|
+
/**
|
|
17
|
+
* Keep the proxy log under the same cap as the daemon log, without a stat per write.
|
|
18
|
+
*
|
|
19
|
+
* Returns the new "bytes since the last check" counter, so the accounting is testable without a
|
|
20
|
+
* filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
|
|
21
|
+
* daemon log on the SAME machine stayed small, which is the whole of this defect.
|
|
22
|
+
*/
|
|
23
|
+
export declare function accountProxyLogWrite(bytesSinceCheck: number, written: number, path: string, deps: {
|
|
24
|
+
fileSize(p: string): number;
|
|
25
|
+
renameFile(from: string, to: string): void;
|
|
26
|
+
}): number;
|
|
27
|
+
/**
|
|
28
|
+
* Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
|
|
29
|
+
*
|
|
30
|
+
* Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
|
|
31
|
+
* builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
|
|
32
|
+
* failure surfaced as the operating system refusing to write files. Files like that exist on real
|
|
33
|
+
* machines now, and the cap alone does not help them: the user should not have to find one with `du`.
|
|
34
|
+
*
|
|
35
|
+
* TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
|
|
36
|
+
* and a file that a running process still holds open keeps its blocks allocated after an unlink
|
|
37
|
+
* until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
|
|
38
|
+
* and why that is the operation to copy here.
|
|
39
|
+
*
|
|
40
|
+
* Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
|
|
41
|
+
* housekeeping is strictly worse than a large file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function recoverOversizedProxyLog(path: string, deps: {
|
|
44
|
+
fileSize(p: string): number;
|
|
45
|
+
truncateFile(p: string): void;
|
|
46
|
+
}, max?: number): number;
|
|
47
|
+
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
48
|
+
export declare function setProxyLogPort(port: number): void;
|
|
49
|
+
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
50
|
+
export declare function proxyLogPath(port?: number | undefined): string;
|
|
51
|
+
/**
|
|
52
|
+
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
53
|
+
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
54
|
+
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
55
|
+
*
|
|
56
|
+
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
57
|
+
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
58
|
+
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
59
|
+
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
60
|
+
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
61
|
+
* existed, and the one path that most needed it was not using it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function proxyLog(event: string, fields?: Record<string, unknown>): void;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP proxy's own log file: where it lives, what bounds it, and how it is written.
|
|
3
|
+
*
|
|
4
|
+
* Split out of mcp-proxy.ts, which is the transport. This is housekeeping for a file, and the two
|
|
5
|
+
* share nothing but a port number — the proxy calls `proxyLog`, and everything else here exists to
|
|
6
|
+
* keep that file from becoming the largest thing on the user's disk.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync, mkdirSync, renameSync, statSync, truncateSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
12
|
+
import { log } from '../log.js';
|
|
13
|
+
import { MAX_DAEMON_LOG_BYTES, rotateDaemonLog } from '../daemon/daemon.js';
|
|
14
|
+
/**
|
|
15
|
+
* Which port this proxy serves, for the log file name. Set once at startup.
|
|
16
|
+
*
|
|
17
|
+
* One file per port, matching `daemon-<port>.log`. A single shared file interleaved every proxy on
|
|
18
|
+
* the machine into one stream, so the first question anyone asks of it — "what happened to MY
|
|
19
|
+
* session?" — needed a filter before it could be answered.
|
|
20
|
+
*/
|
|
21
|
+
let logPort;
|
|
22
|
+
/**
|
|
23
|
+
* How much the proxy may append between size checks.
|
|
24
|
+
*
|
|
25
|
+
* Statting the file on every line would be a syscall per log line on a process that logs from a
|
|
26
|
+
* reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
|
|
27
|
+
* the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
|
|
28
|
+
*/
|
|
29
|
+
export const PROXY_LOG_CHECK_BYTES = 256 * 1024;
|
|
30
|
+
/** Appended since the last real size check. Reset by every check, whatever the check decides. */
|
|
31
|
+
let bytesSinceProxyLogCheck = 0;
|
|
32
|
+
/**
|
|
33
|
+
* Keep the proxy log under the same cap as the daemon log, without a stat per write.
|
|
34
|
+
*
|
|
35
|
+
* Returns the new "bytes since the last check" counter, so the accounting is testable without a
|
|
36
|
+
* filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
|
|
37
|
+
* daemon log on the SAME machine stayed small, which is the whole of this defect.
|
|
38
|
+
*/
|
|
39
|
+
export function accountProxyLogWrite(bytesSinceCheck, written, path, deps) {
|
|
40
|
+
const total = bytesSinceCheck + written;
|
|
41
|
+
if (PROXY_LOG_CHECK_BYTES > total)
|
|
42
|
+
return total;
|
|
43
|
+
rotateDaemonLog(path, deps);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
|
|
48
|
+
*
|
|
49
|
+
* Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
|
|
50
|
+
* builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
|
|
51
|
+
* failure surfaced as the operating system refusing to write files. Files like that exist on real
|
|
52
|
+
* machines now, and the cap alone does not help them: the user should not have to find one with `du`.
|
|
53
|
+
*
|
|
54
|
+
* TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
|
|
55
|
+
* and a file that a running process still holds open keeps its blocks allocated after an unlink
|
|
56
|
+
* until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
|
|
57
|
+
* and why that is the operation to copy here.
|
|
58
|
+
*
|
|
59
|
+
* Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
|
|
60
|
+
* housekeeping is strictly worse than a large file.
|
|
61
|
+
*/
|
|
62
|
+
export function recoverOversizedProxyLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
|
|
63
|
+
try {
|
|
64
|
+
const size = deps.fileSize(path);
|
|
65
|
+
if (max >= size)
|
|
66
|
+
return 0;
|
|
67
|
+
deps.truncateFile(path);
|
|
68
|
+
return size;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The real filesystem behind the two rotation helpers. */
|
|
75
|
+
const proxyLogFileOps = {
|
|
76
|
+
fileSize: (p) => statSync(p).size,
|
|
77
|
+
renameFile: (from, to) => renameSync(from, to),
|
|
78
|
+
truncateFile: (p) => truncateSync(p, 0),
|
|
79
|
+
};
|
|
80
|
+
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
81
|
+
export function setProxyLogPort(port) {
|
|
82
|
+
logPort = port;
|
|
83
|
+
// The one moment we know a size check is worth its syscall, and the only place a file inherited
|
|
84
|
+
// from an older build can be brought back under control.
|
|
85
|
+
const reclaimed = recoverOversizedProxyLog(proxyLogPath(port), proxyLogFileOps);
|
|
86
|
+
if (0 < reclaimed) {
|
|
87
|
+
proxyLog('reticle_mcp_proxy_log_truncated', {
|
|
88
|
+
port,
|
|
89
|
+
reclaimedBytes: reclaimed,
|
|
90
|
+
note: 'the proxy log had grown past its cap and was truncated in place to reclaim the space',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
95
|
+
export function proxyLogPath(port = logPort) {
|
|
96
|
+
const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
|
|
97
|
+
return join(homedir(), ReticleDir.ROOT, name);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
101
|
+
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
102
|
+
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
103
|
+
*
|
|
104
|
+
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
105
|
+
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
106
|
+
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
107
|
+
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
108
|
+
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
109
|
+
* existed, and the one path that most needed it was not using it.
|
|
110
|
+
*/
|
|
111
|
+
export function proxyLog(event, fields = {}) {
|
|
112
|
+
log(event, fields);
|
|
113
|
+
try {
|
|
114
|
+
const dir = join(homedir(), ReticleDir.ROOT);
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
// WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
|
|
117
|
+
// without one it cannot answer the two questions anyone brings to it: when did this happen, and
|
|
118
|
+
// how often. A real 3,283-line log on a dev machine held a `gave_up` — the exact event that used
|
|
119
|
+
// to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
|
|
120
|
+
// impossible to tell whether it happened during that person's work or a test run hours earlier.
|
|
121
|
+
// Evidence you cannot place in time is an anecdote.
|
|
122
|
+
const path = proxyLogPath();
|
|
123
|
+
const line = `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`;
|
|
124
|
+
appendFileSync(path, line, 'utf8');
|
|
125
|
+
// A single proxy process is long-lived and logs from a reconnect loop, so rotating only at
|
|
126
|
+
// startup would leave the growth this file is capable of entirely unchecked while it runs.
|
|
127
|
+
bytesSinceProxyLogCheck = accountProxyLogWrite(bytesSinceProxyLogCheck, Buffer.byteLength(line, 'utf8'), path, proxyLogFileOps);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Logging must never be the thing that kills the proxy.
|
|
131
|
+
}
|
|
132
|
+
}
|