@reticlehq/server 2.7.0 → 2.8.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 +64 -29
- package/dist/capsule/causal-summary.d.ts +20 -0
- package/dist/capsule/causal-summary.js +31 -6
- 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 +28 -25
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-launch.d.ts +17 -0
- package/dist/cli/cli-launch.js +28 -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 +8 -0
- package/dist/cli/cli-port.js +16 -0
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +66 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli.js +99 -14
- 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 +14 -0
- package/dist/events/contradictions.js +26 -17
- package/dist/events/predicate-eval.d.ts +20 -1
- package/dist/events/predicate-eval.js +125 -3
- package/dist/events/predicate.js +170 -18
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-tools.js +12 -4
- package/dist/flows/heal-run.js +1 -1
- package/dist/honesty/blind-spots.js +6 -2
- package/dist/honesty/unsettled.d.ts +40 -0
- package/dist/honesty/unsettled.js +78 -0
- package/dist/honesty/verified.d.ts +7 -0
- package/dist/honesty/verified.js +3 -3
- package/dist/index.js +3 -0
- package/dist/init/agent-rules.d.ts +16 -0
- package/dist/init/agent-rules.js +96 -22
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +11 -5
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +96 -0
- package/dist/init/confirm.js +138 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/non-js-project.js +29 -5
- package/dist/init/plan-framework.js +53 -15
- package/dist/init/plan.d.ts +40 -4
- package/dist/init/plan.js +163 -40
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +148 -42
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +46 -6
- package/dist/init/snippets.d.ts +44 -4
- package/dist/init/snippets.js +161 -37
- package/dist/init/vite-config.js +29 -3
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +29 -4
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +69 -3
- package/dist/mcp/mcp-proxy.js +205 -20
- package/dist/mcp/mcp.js +23 -7
- package/dist/mcp/proxy-lifecycle.d.ts +1 -1
- package/dist/mcp/proxy-lifecycle.js +1 -1
- 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/session/no-session-diagnosis.d.ts +19 -0
- package/dist/session/no-session-diagnosis.js +71 -18
- package/dist/session/no-session-watch.js +12 -0
- 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/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +0 -20
- package/dist/telemetry/bug-found.js +38 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +36 -0
- package/dist/telemetry/install-source.js +40 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.js +5 -0
- package/dist/telemetry/telemetry.d.ts +5 -1
- package/dist/telemetry/telemetry.js +15 -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 +45 -1
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +18 -1
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +64 -4
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +139 -23
- package/dist/tools/lease-tools.d.ts +29 -1
- package/dist/tools/lease-tools.js +58 -5
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +1 -1
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +48 -0
- package/dist/tools/react-grace.js +87 -0
- package/dist/tools/read-tools.js +20 -4
- package/dist/tools/settle-in-flight.d.ts +79 -0
- package/dist/tools/settle-in-flight.js +106 -0
- package/dist/tools/snapshot-delta.d.ts +7 -0
- package/dist/tools/snapshot-delta.js +130 -6
- package/dist/tools/tool-surface.js +19 -0
- package/dist/tools/tools.js +16 -4
- package/docs/README.md +56 -11
- package/docs/actions.mdx +87 -0
- package/docs/agent-cheatsheet.md +93 -29
- package/docs/architecture.md +46 -35
- package/docs/benchmarks.md +76 -72
- package/docs/best-practices.mdx +211 -0
- package/docs/capabilities.mdx +387 -0
- package/docs/cli/affected.mdx +51 -0
- package/docs/cli/capsules.mdx +69 -0
- package/docs/cli/cloud.mdx +127 -0
- package/docs/cli/doctor.mdx +76 -0
- package/docs/cli/drive.mdx +55 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +92 -0
- package/docs/cli/hunt.mdx +57 -0
- package/docs/cli/identify.mdx +62 -0
- package/docs/cli/init.mdx +94 -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 +68 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +62 -0
- package/docs/cli/status.mdx +64 -0
- package/docs/cli/stop.mdx +56 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +57 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +55 -0
- package/docs/cli.mdx +219 -0
- package/docs/debugging.md +13 -9
- package/docs/deploy-checks.md +15 -11
- package/docs/desktop-apps.md +49 -40
- package/docs/desktop.mdx +188 -0
- package/docs/docs.json +363 -0
- package/docs/enterprise.md +20 -16
- package/docs/faq.mdx +88 -0
- package/docs/fixtures.md +11 -7
- package/docs/flows.md +26 -22
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +199 -0
- package/docs/gate-plan.md +45 -41
- package/docs/gates.md +22 -18
- package/docs/getting-started.md +76 -65
- package/docs/human-control.md +22 -18
- package/docs/index.mdx +101 -0
- package/docs/install-agentic.mdx +215 -0
- package/docs/install-manual.mdx +194 -0
- package/docs/instrumentation.mdx +232 -0
- package/docs/integration-patterns.md +38 -34
- package/docs/local-registry.md +16 -12
- package/docs/multi-agent-testing.md +16 -12
- package/docs/packages/babel-plugin.mdx +70 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +106 -0
- package/docs/packages/electron.mdx +87 -0
- package/docs/packages/eslint-plugin.mdx +84 -0
- package/docs/packages/next.mdx +91 -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 +108 -0
- package/docs/packages/vite-plugin.mdx +96 -0
- package/docs/packages.mdx +217 -0
- package/docs/platform-integration.md +33 -26
- package/docs/predicates.mdx +179 -0
- package/docs/quickstart.mdx +211 -0
- package/docs/recipes.mdx +242 -0
- package/docs/skill-file.mdx +105 -0
- package/docs/state-management.mdx +197 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +47 -32
- package/docs/telemetry-contract.md +93 -50
- package/docs/telemetry-events.mdx +430 -0
- package/docs/telemetry.md +57 -52
- package/docs/testing.md +10 -6
- package/docs/token-efficiency.md +30 -26
- package/docs/tools-act-and-wait.mdx +123 -0
- package/docs/tools-act-sequence.mdx +120 -0
- package/docs/tools-act.mdx +82 -0
- package/docs/tools-assert.mdx +70 -0
- package/docs/tools-console.mdx +59 -0
- package/docs/tools-inspect.mdx +85 -0
- package/docs/tools-navigate.mdx +63 -0
- package/docs/tools-network.mdx +86 -0
- package/docs/tools-observe.mdx +89 -0
- package/docs/tools-overview.mdx +157 -0
- package/docs/tools-query.mdx +82 -0
- package/docs/tools-session-and-feedback.mdx +91 -0
- package/docs/tools-sessions.mdx +65 -0
- package/docs/tools-snapshot.mdx +75 -0
- package/docs/tools-state.mdx +81 -0
- package/docs/tools-tools-and-run.mdx +125 -0
- package/docs/tools-wait-for.mdx +66 -0
- package/docs/troubleshooting.mdx +193 -0
- package/docs/usage.md +175 -153
- package/docs/vs-chrome-devtools-mcp.mdx +82 -0
- package/docs/vs-playwright-mcp.mdx +85 -0
- package/docs/vs-screenshots.mdx +96 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +107 -0
- package/package.json +2 -2
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as http from 'node:http';
|
|
|
2
2
|
import { localInitializeResponse, isHandshakeLine } from './proxy-handshake.js';
|
|
3
3
|
import { ToolCatalogCache } from './tool-catalog-cache.js';
|
|
4
4
|
import * as net from 'node:net';
|
|
5
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { appendFileSync, mkdirSync, renameSync, statSync, truncateSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
@@ -10,6 +10,7 @@ import { SERVER_VERSION } from '../version/server-version.js';
|
|
|
10
10
|
import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
|
|
11
11
|
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, onQueueExpired, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
12
12
|
import { log } from '../log.js';
|
|
13
|
+
import { MAX_DAEMON_LOG_BYTES, rotateDaemonLog } from '../daemon/daemon.js';
|
|
13
14
|
import { OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
14
15
|
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
15
16
|
/**
|
|
@@ -59,9 +60,77 @@ export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
|
|
|
59
60
|
* session?" — needed a filter before it could be answered.
|
|
60
61
|
*/
|
|
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
|
+
};
|
|
62
121
|
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
63
122
|
export function setProxyLogPort(port) {
|
|
64
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
|
+
}
|
|
65
134
|
}
|
|
66
135
|
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
67
136
|
export function proxyLogPath(port = logPort) {
|
|
@@ -91,7 +160,12 @@ export function proxyLog(event, fields = {}) {
|
|
|
91
160
|
// to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
|
|
92
161
|
// impossible to tell whether it happened during that person's work or a test run hours earlier.
|
|
93
162
|
// Evidence you cannot place in time is an anecdote.
|
|
94
|
-
|
|
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);
|
|
95
169
|
}
|
|
96
170
|
catch {
|
|
97
171
|
// Logging must never be the thing that kills the proxy.
|
|
@@ -166,8 +240,10 @@ export class PendingRequests {
|
|
|
166
240
|
* daemon under a live client left 20 of 20 concurrent calls hanging until the client's own timeout,
|
|
167
241
|
* with the MCP server still perfectly alive. An agent cannot tell that apart from "still working".
|
|
168
242
|
*/
|
|
169
|
-
export function streamLossReplies(pending, reason) {
|
|
170
|
-
return pending
|
|
243
|
+
export function streamLossReplies(pending, reason, nextStep) {
|
|
244
|
+
return pending
|
|
245
|
+
.drain()
|
|
246
|
+
.map((id) => transportLossReply(JSON.parse(id), reason, nextStep));
|
|
171
247
|
}
|
|
172
248
|
/**
|
|
173
249
|
* -32001: a server-defined error. Not -32603 (internal): nothing went wrong INSIDE the call, the
|
|
@@ -175,15 +251,21 @@ export function streamLossReplies(pending, reason) {
|
|
|
175
251
|
* to retry.
|
|
176
252
|
*/
|
|
177
253
|
const TRANSPORT_LOSS_CODE = -32001;
|
|
178
|
-
/**
|
|
179
|
-
|
|
254
|
+
/**
|
|
255
|
+
* The one shape every "the transport ate your call" answer takes, whichever leg died.
|
|
256
|
+
*
|
|
257
|
+
* `nextStep` is appended when the caller knows one. Without it this reply describes a condition and
|
|
258
|
+
* stops there, which is all a first-run user got out of their first ever tool call.
|
|
259
|
+
*/
|
|
260
|
+
export function transportLossReply(id, reason, nextStep) {
|
|
180
261
|
return JSON.stringify({
|
|
181
262
|
jsonrpc: '2.0',
|
|
182
263
|
id,
|
|
183
264
|
error: {
|
|
184
265
|
code: TRANSPORT_LOSS_CODE,
|
|
185
266
|
message: `the daemon connection dropped (${reason}) before this call was answered — it did NOT ` +
|
|
186
|
-
'complete. Reticle is recovering the connection; retry if the action is safe to repeat.'
|
|
267
|
+
'complete. Reticle is recovering the connection; retry if the action is safe to repeat.' +
|
|
268
|
+
(nextStep === undefined ? '' : ` ${nextStep}`),
|
|
187
269
|
},
|
|
188
270
|
});
|
|
189
271
|
}
|
|
@@ -207,8 +289,38 @@ const SHUTDOWN_DRAIN_MS = 5_000;
|
|
|
207
289
|
* well under the 60s an MCP client typically allows, so the answer comes from us with a reason
|
|
208
290
|
* rather than from their timer with none.
|
|
209
291
|
*/
|
|
210
|
-
const QUEUE_WAIT_MS = 20_000;
|
|
292
|
+
export const QUEUE_WAIT_MS = 20_000;
|
|
211
293
|
const SHUTDOWN_DRAIN_POLL_MS = 25;
|
|
294
|
+
/** A port that refuses is a port nothing is listening on YET — not a port that answered badly. */
|
|
295
|
+
const CONNECTION_REFUSED = 'ECONNREFUSED';
|
|
296
|
+
/**
|
|
297
|
+
* How long the FIRST connect keeps chasing a refused port before calling it a startup failure.
|
|
298
|
+
*
|
|
299
|
+
* It used to get exactly one attempt. Reported from a Windows first bootstrap: the daemon was still
|
|
300
|
+
* booting, the single connect was refused, and nothing tried again — only a fresh CLIENT request
|
|
301
|
+
* re-probes the port, and the client was already blocked on the call that then expired with -32001.
|
|
302
|
+
* The first tool call of somebody's first ever session is the worst place available to lose one.
|
|
303
|
+
*
|
|
304
|
+
* Deliberately longer than QUEUE_WAIT_MS: a retry that stops before the queue does cannot rescue the
|
|
305
|
+
* request it exists for, and a daemon found afterwards at least restores service for the next call.
|
|
306
|
+
*/
|
|
307
|
+
const FIRST_CONNECT_WINDOW_MS = QUEUE_WAIT_MS + 15_000;
|
|
308
|
+
/** Retry a first connect, or report it? Pure, so the window can be held against the queue's. */
|
|
309
|
+
export function shouldRetryFirstConnect(error, elapsedMs) {
|
|
310
|
+
const code = error?.code;
|
|
311
|
+
return CONNECTION_REFUSED === code && FIRST_CONNECT_WINDOW_MS > elapsedMs;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* What to do about it, carried on the reply the queue expiry sends.
|
|
315
|
+
*
|
|
316
|
+
* A bare -32001 on the first call after an install says nothing a user can act on: not whether to
|
|
317
|
+
* wait, not whether to retry, not whether the install itself is broken.
|
|
318
|
+
*/
|
|
319
|
+
export function queueExpiryNextStep(port) {
|
|
320
|
+
return (`No Reticle daemon answered on port ${String(port)}. Retry the call — the next request starts ` +
|
|
321
|
+
'one. If it keeps failing, run `reticle doctor` in a terminal: it reports what is holding the ' +
|
|
322
|
+
'port and whether the daemon can start at all.');
|
|
323
|
+
}
|
|
212
324
|
/**
|
|
213
325
|
* Makes a reconnect invisible to the client.
|
|
214
326
|
*
|
|
@@ -439,8 +551,9 @@ ensureDaemon) {
|
|
|
439
551
|
return;
|
|
440
552
|
// Drop what we could never send, and answer it, so the caller is never left guessing.
|
|
441
553
|
stdinQueue.splice(0);
|
|
442
|
-
for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
|
|
554
|
+
for (const reply of streamLossReplies(pending, 'no daemon could be reached', queueExpiryNextStep(port))) {
|
|
443
555
|
emit(reply);
|
|
556
|
+
}
|
|
444
557
|
// The expiry is the proof that the reconnect we believed was in flight is not coming — a
|
|
445
558
|
// wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
|
|
446
559
|
// rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
|
|
@@ -464,6 +577,15 @@ ensureDaemon) {
|
|
|
464
577
|
};
|
|
465
578
|
let stopped = false;
|
|
466
579
|
let attempts = 0;
|
|
580
|
+
/**
|
|
581
|
+
* When the proxy started, and how many times the first connect has been refused since.
|
|
582
|
+
*
|
|
583
|
+
* The clock is read at this boundary rather than injected, matching the shutdown drain below:
|
|
584
|
+
* it measures how long a real daemon has had to boot, which is a wall-clock fact about the
|
|
585
|
+
* machine and not a decision the caller could supply.
|
|
586
|
+
*/
|
|
587
|
+
const startedAt = Date.now();
|
|
588
|
+
let firstConnectAttempts = 0;
|
|
467
589
|
/**
|
|
468
590
|
* The ONE way a line leaves for the daemon — so a POST that never lands still owes an answer.
|
|
469
591
|
*
|
|
@@ -509,6 +631,7 @@ ensureDaemon) {
|
|
|
509
631
|
clearQueueTimer();
|
|
510
632
|
for (const queued of stdinQueue.splice(0))
|
|
511
633
|
forward(url, queued);
|
|
634
|
+
handshakeAnswered = true;
|
|
512
635
|
return;
|
|
513
636
|
}
|
|
514
637
|
if ('message' === event && !replay.shouldSuppressInbound(data)) {
|
|
@@ -518,11 +641,27 @@ ensureDaemon) {
|
|
|
518
641
|
emit(data);
|
|
519
642
|
}
|
|
520
643
|
}
|
|
521
|
-
function scheduleReconnect(reason, detail
|
|
644
|
+
function scheduleReconnect(reason, detail) {
|
|
522
645
|
if (stopped)
|
|
523
646
|
return;
|
|
524
647
|
postUrl = null;
|
|
525
648
|
attempts++;
|
|
649
|
+
// Everything the dead session owed is settled HERE, in the one place every drop funnels
|
|
650
|
+
// through, and not in the response callback that first noticed the socket die. A reset emits
|
|
651
|
+
// on BOTH halves of the request, so whichever handler wins decides what gets paid — the same
|
|
652
|
+
// shape as the reconnect fan-out, and it left the other half paying nothing.
|
|
653
|
+
//
|
|
654
|
+
// Order matters between the two. Drain the queue FIRST: a queued request is tracked in
|
|
655
|
+
// `pending` as well, so answering without draining leaves the next `endpoint` frame free to
|
|
656
|
+
// flush it into the fresh session, where the daemon answers the same id a second time and the
|
|
657
|
+
// client sees two responses for one request. Then answer, because the new session cannot know
|
|
658
|
+
// what the old one owed and silence here is permanent.
|
|
659
|
+
stdinQueue.splice(0);
|
|
660
|
+
const lost = streamLossReplies(pending, reason);
|
|
661
|
+
for (const reply of lost)
|
|
662
|
+
emit(reply);
|
|
663
|
+
// How many calls this drop actually killed. Zero means the agent could not feel it.
|
|
664
|
+
const pendingLost = lost.length;
|
|
526
665
|
// Once per process: this session has now lost its tools at least once, which is the number
|
|
527
666
|
// that says whether the transport work actually landed for real users. `pendingLost` is the
|
|
528
667
|
// part an agent can FEEL — almost every measured drop killed nothing in flight.
|
|
@@ -573,23 +712,45 @@ ensureDaemon) {
|
|
|
573
712
|
// Announce this process to the daemon on the connect we already make — it is the single judge
|
|
574
713
|
// of version skew, and this is the only moment it learns the agent's MCP server exists.
|
|
575
714
|
const announce = `${MCP_SSE_PATH}?${PEER_VERSION_PARAM}=${encodeURIComponent(SERVER_VERSION)}&${PEER_CONTRACT_PARAM}=${encodeURIComponent(CONTRACT_FINGERPRINT)}`;
|
|
715
|
+
/**
|
|
716
|
+
* ONE reconnect per connect attempt, whoever notices the failure first.
|
|
717
|
+
*
|
|
718
|
+
* A dead socket emits on BOTH halves — `req`'s `error` and the response's `aborted`/`close`
|
|
719
|
+
* for the same TCP death — and this latch used to live inside the response callback, so
|
|
720
|
+
* `req.on('error')` scheduled a SECOND reconnect that the drop path could not see. Two chains
|
|
721
|
+
* came back, both died the same way, and both split again. The retry budget cannot stop that
|
|
722
|
+
* either: any one chain reaching an `endpoint` frame resets `attempts` to zero for all of
|
|
723
|
+
* them. It is the runaway behind a proxy log large enough to fill a disk, and the doubling is
|
|
724
|
+
* visible in the field logs as roughly two `reconnecting` lines per `reconnected` one.
|
|
725
|
+
*/
|
|
726
|
+
let settled = false;
|
|
727
|
+
/**
|
|
728
|
+
* Whether this attempt ever got a response, as opposed to never reaching the daemon.
|
|
729
|
+
*
|
|
730
|
+
* `first` alone conflates two different situations, and the `req.on('error')` branch below
|
|
731
|
+
* treats both as a startup failure the caller handles. Before any stream exists that is right:
|
|
732
|
+
* there is no daemon, so rejecting is the honest answer. Once the FIRST stream has connected
|
|
733
|
+
* and is serving, a socket reset is a DROP, and rejecting it kills the proxy outright instead
|
|
734
|
+
* of reconnecting. The agent loses Reticle for the rest of the session, on its first stream,
|
|
735
|
+
* with nothing to retry it.
|
|
736
|
+
*
|
|
737
|
+
* It is also a race rather than a certainty, which is worse: a reset emits on both halves, so
|
|
738
|
+
* whether the proxy reconnects or dies depended on whether `res`'s `close` or `req`'s `error`
|
|
739
|
+
* won. Every later stream recovers correctly; you simply have to survive the first one.
|
|
740
|
+
*/
|
|
741
|
+
let responded = false;
|
|
576
742
|
const req = http.get({ host: LOOPBACK_HOST, port, path: announce }, (res) => {
|
|
743
|
+
responded = true;
|
|
577
744
|
if (!first)
|
|
578
745
|
proxyLog('reticle_mcp_proxy_reconnected', { port });
|
|
579
746
|
res.setEncoding('utf8');
|
|
580
|
-
// `end` and `error` can both fire on one response; only the first should drive a reconnect.
|
|
581
|
-
let settled = false;
|
|
582
747
|
const drop = (reason, detail) => {
|
|
583
748
|
if (settled)
|
|
584
749
|
return;
|
|
585
750
|
settled = true;
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
for (const reply of lost)
|
|
590
|
-
emit(reply);
|
|
591
|
-
// How many calls this drop actually killed. Zero means the agent could not feel it.
|
|
592
|
-
scheduleReconnect(reason, detail, lost.length);
|
|
751
|
+
// What the dead session owed is settled inside scheduleReconnect, so the other half of the
|
|
752
|
+
// socket's death pays exactly the same debt when it is the one that notices first.
|
|
753
|
+
scheduleReconnect(reason, detail);
|
|
593
754
|
};
|
|
594
755
|
const sse = new SseFrameParser();
|
|
595
756
|
res.on('data', (chunk) => {
|
|
@@ -606,7 +767,31 @@ ensureDaemon) {
|
|
|
606
767
|
});
|
|
607
768
|
// The very first connect failing means there is no daemon to talk to — that is a startup
|
|
608
769
|
// failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
|
|
609
|
-
req.on('error', (err) =>
|
|
770
|
+
req.on('error', (err) => {
|
|
771
|
+
if (settled)
|
|
772
|
+
return;
|
|
773
|
+
settled = true;
|
|
774
|
+
if (first && !responded) {
|
|
775
|
+
// A refused port on the very first connect is a daemon still booting, not a failure. Keep
|
|
776
|
+
// chasing it: nothing else will, because only a fresh client request re-probes the port
|
|
777
|
+
// and the client is already blocked on the call this would rescue.
|
|
778
|
+
if (shouldRetryFirstConnect(err, Date.now() - startedAt)) {
|
|
779
|
+
firstConnectAttempts++;
|
|
780
|
+
const retryInMs = reconnectDelayMs(firstConnectAttempts);
|
|
781
|
+
proxyLog('reticle_mcp_proxy_first_connect_retry', {
|
|
782
|
+
port,
|
|
783
|
+
attempt: firstConnectAttempts,
|
|
784
|
+
retryInMs,
|
|
785
|
+
note: 'the daemon has not finished booting; still waiting for it to accept',
|
|
786
|
+
});
|
|
787
|
+
setTimeout(() => connect(true), retryInMs).unref();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
reject(err);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
scheduleReconnect('connect_error', err.message);
|
|
794
|
+
});
|
|
610
795
|
}
|
|
611
796
|
connect(true);
|
|
612
797
|
// ── stdin reader ─────────────────────────────────────────────────────────
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -353,15 +353,31 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
|
|
|
353
353
|
// session says which one it saw.
|
|
354
354
|
getSessionMetrics().recordSurface(profile);
|
|
355
355
|
const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Record WHICH agent attached, at the handshake, for every session.
|
|
358
|
+
*
|
|
359
|
+
* `clients` and `clientVersions` were populated for almost nobody, and the reason was not that
|
|
360
|
+
* clients withhold `clientInfo` — they send it, and we read it. It was that the ONLY thing that
|
|
361
|
+
* ever called `recordClient` was the feedback hook below, which runs when a report is being built.
|
|
362
|
+
* So the field was really measuring "sessions that filed feedback", and a product whose users are
|
|
363
|
+
* agents could not say which agent, on which build, any of its numbers came from.
|
|
364
|
+
*
|
|
365
|
+
* `oninitialized` is the moment `clientInfo` first exists — reading it at construction time gets
|
|
366
|
+
* `undefined`, which is what made a lazy hook look like the only option.
|
|
367
|
+
*/
|
|
368
|
+
server.server.oninitialized = () => {
|
|
369
|
+
const info = server.server.getClientVersion();
|
|
370
|
+
if (info?.name !== undefined)
|
|
371
|
+
getSessionMetrics().recordClient(info.name, info.version);
|
|
372
|
+
};
|
|
373
|
+
// Which agent is on the other end, for a feedback report. Registered as a lazy hook rather than
|
|
374
|
+
// read here: the handshake has not happened yet at construction time, and a report filed twenty
|
|
375
|
+
// tool calls later is exactly when we want the answer.
|
|
359
376
|
setMcpClientNameHook(() => {
|
|
360
377
|
const info = server.server.getClientVersion();
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// regression in agent behaviour could never be attributed to a client build.
|
|
378
|
+
// Still records, because a client that skipped the handshake path above (a replayed initialize
|
|
379
|
+
// through the proxy, a transport that reconnects) is a client we would otherwise lose entirely.
|
|
380
|
+
// `recordClient` is idempotent per name.
|
|
365
381
|
if (info?.name !== undefined)
|
|
366
382
|
getSessionMetrics().recordClient(info.name, info.version);
|
|
367
383
|
return info === undefined ? undefined : { name: info.name, version: info.version };
|
|
@@ -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
|
|
@@ -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
|
|
@@ -60,7 +60,8 @@ interface BrowserPoolOptions {
|
|
|
60
60
|
export declare class BrowserPool {
|
|
61
61
|
#private;
|
|
62
62
|
constructor(launch: Launcher, opts: BrowserPoolOptions);
|
|
63
|
-
/**
|
|
63
|
+
/** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
|
|
64
|
+
leaseTtlMs(): number;
|
|
64
65
|
/** Max simultaneous leases — the ceiling the parallel suite sizes its concurrency to. */
|
|
65
66
|
capacity(): number;
|
|
66
67
|
activeCount(): number;
|
|
@@ -68,8 +69,27 @@ export declare class BrowserPool {
|
|
|
68
69
|
queuedCount(): number;
|
|
69
70
|
/** The sessionIds of every currently leased context — used to report/group leased sessions. */
|
|
70
71
|
leasedSessionIds(): string[];
|
|
71
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown.
|
|
74
|
+
*
|
|
75
|
+
* Resolves an alias first, because the agent releases with the id it was GIVEN, which for an app
|
|
76
|
+
* that named its own session is not the id the pool navigated with. Releasing by that name used to
|
|
77
|
+
* be a silent no-op that left the slot held until the reaper took it.
|
|
78
|
+
*/
|
|
72
79
|
release(sessionId: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Record that a lease is also known by the name the APP registered.
|
|
82
|
+
*
|
|
83
|
+
* Leases are keyed by the id the pool navigated with, and the SDK normally adopts that id off the
|
|
84
|
+
* URL so the two agree. An app that passes an explicit `session` to `connect()` keeps its own name
|
|
85
|
+
* instead, which is legitimate, and the lease tool then hands the agent THAT name because it is the
|
|
86
|
+
* one it has to drive with. Every later touch and release arrives under it.
|
|
87
|
+
*
|
|
88
|
+
* Without this the touches hit nothing, the lease ages out despite continuous activity, and the
|
|
89
|
+
* reaper closes the context mid-flow — the failure in #157, where a session died on the very call
|
|
90
|
+
* that would have read a computed value.
|
|
91
|
+
*/
|
|
92
|
+
alias(registeredId: string, leaseId: string): void;
|
|
73
93
|
/** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
|
|
74
94
|
touch(sessionId: string): void;
|
|
75
95
|
/**
|
|
@@ -30,6 +30,8 @@ export class BrowserPool {
|
|
|
30
30
|
/** Leases reclaimed for going stale — see reapedLeaseCount(). */
|
|
31
31
|
#reapedLeases = 0;
|
|
32
32
|
#active = new Map();
|
|
33
|
+
/** Registered-session-id → lease id, for apps that keep their own session name. See `alias`. */
|
|
34
|
+
#aliases = new Map();
|
|
33
35
|
/**
|
|
34
36
|
* Slots claimed-or-active — the real concurrency gate. Incremented SYNCHRONOUSLY the instant an
|
|
35
37
|
* acquire passes the cap check, BEFORE the async context creation, so a burst of concurrent
|
|
@@ -50,7 +52,10 @@ export class BrowserPool {
|
|
|
50
52
|
this.#ttl = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
51
53
|
this.#navTimeout = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
|
|
52
54
|
}
|
|
53
|
-
/**
|
|
55
|
+
/** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
|
|
56
|
+
leaseTtlMs() {
|
|
57
|
+
return this.#ttl;
|
|
58
|
+
}
|
|
54
59
|
/** Max simultaneous leases — the ceiling the parallel suite sizes its concurrency to. */
|
|
55
60
|
capacity() {
|
|
56
61
|
return this.#max;
|
|
@@ -66,13 +71,42 @@ export class BrowserPool {
|
|
|
66
71
|
leasedSessionIds() {
|
|
67
72
|
return [...this.#active.keys()];
|
|
68
73
|
}
|
|
69
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown.
|
|
76
|
+
*
|
|
77
|
+
* Resolves an alias first, because the agent releases with the id it was GIVEN, which for an app
|
|
78
|
+
* that named its own session is not the id the pool navigated with. Releasing by that name used to
|
|
79
|
+
* be a silent no-op that left the slot held until the reaper took it.
|
|
80
|
+
*/
|
|
70
81
|
release(sessionId) {
|
|
71
|
-
return this.#release(sessionId);
|
|
82
|
+
return this.#release(this.#leaseIdOf(sessionId));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Record that a lease is also known by the name the APP registered.
|
|
86
|
+
*
|
|
87
|
+
* Leases are keyed by the id the pool navigated with, and the SDK normally adopts that id off the
|
|
88
|
+
* URL so the two agree. An app that passes an explicit `session` to `connect()` keeps its own name
|
|
89
|
+
* instead, which is legitimate, and the lease tool then hands the agent THAT name because it is the
|
|
90
|
+
* one it has to drive with. Every later touch and release arrives under it.
|
|
91
|
+
*
|
|
92
|
+
* Without this the touches hit nothing, the lease ages out despite continuous activity, and the
|
|
93
|
+
* reaper closes the context mid-flow — the failure in #157, where a session died on the very call
|
|
94
|
+
* that would have read a computed value.
|
|
95
|
+
*/
|
|
96
|
+
alias(registeredId, leaseId) {
|
|
97
|
+
if (registeredId === leaseId)
|
|
98
|
+
return;
|
|
99
|
+
if (!this.#active.has(leaseId))
|
|
100
|
+
return;
|
|
101
|
+
this.#aliases.set(registeredId, leaseId);
|
|
102
|
+
}
|
|
103
|
+
/** The lease this id refers to: itself, or whatever it is an alias for. */
|
|
104
|
+
#leaseIdOf(sessionId) {
|
|
105
|
+
return this.#active.has(sessionId) ? sessionId : (this.#aliases.get(sessionId) ?? sessionId);
|
|
72
106
|
}
|
|
73
107
|
/** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
|
|
74
108
|
touch(sessionId) {
|
|
75
|
-
const lease = this.#active.get(sessionId);
|
|
109
|
+
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
76
110
|
if (lease !== undefined)
|
|
77
111
|
lease.touchedAt = this.#now();
|
|
78
112
|
}
|
|
@@ -173,6 +207,12 @@ export class BrowserPool {
|
|
|
173
207
|
if (lease === undefined)
|
|
174
208
|
return; // already released or lost to a crash
|
|
175
209
|
this.#active.delete(sessionId);
|
|
210
|
+
// Drop any alias pointing here, or the map grows for the life of the daemon and a later lease
|
|
211
|
+
// reusing that registered name would resolve to a context that is already closed.
|
|
212
|
+
for (const [registered, leaseId] of this.#aliases) {
|
|
213
|
+
if (leaseId === sessionId)
|
|
214
|
+
this.#aliases.delete(registered);
|
|
215
|
+
}
|
|
176
216
|
await lease.context.close().catch(() => undefined);
|
|
177
217
|
this.#releaseSlot();
|
|
178
218
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
+
interface RouteLearningSession {
|
|
3
|
+
url: string;
|
|
4
|
+
onEvent(handler: (event: ReticleEvent) => void): () => void;
|
|
5
|
+
}
|
|
6
|
+
interface RouteLearningBridge {
|
|
7
|
+
sessions: {
|
|
8
|
+
all(): RouteLearningSession[];
|
|
9
|
+
};
|
|
10
|
+
attachSessionReady(handler: (session: RouteLearningSession) => void): void;
|
|
11
|
+
}
|
|
12
|
+
interface RouteLearningStore {
|
|
13
|
+
recordRoutes(routes: readonly string[]): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/** Coalesce navigation bursts so one crawl does not lock and rewrite project.json per event. */
|
|
16
|
+
export declare const ROUTE_PERSIST_DEBOUNCE_MS = 50;
|
|
17
|
+
/** Convert a validated route-change payload into a bounded route identity. */
|
|
18
|
+
export declare function routeFromEvent(event: ReticleEvent): string | undefined;
|
|
19
|
+
/** Read the same route shape from a session's absolute URL. */
|
|
20
|
+
export declare function routeFromUrl(value: string): string | undefined;
|
|
21
|
+
export declare function routesFromEvents(events: readonly ReticleEvent[]): string[];
|
|
22
|
+
/** Persist initial and subsequent routes for existing and future browser sessions. */
|
|
23
|
+
export declare function attachRouteLearning(bridge: RouteLearningBridge, project: RouteLearningStore): void;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { EventType } from '@reticlehq/core';
|
|
2
|
+
/** Coalesce navigation bursts so one crawl does not lock and rewrite project.json per event. */
|
|
3
|
+
export const ROUTE_PERSIST_DEBOUNCE_MS = 50;
|
|
4
|
+
/** Convert a validated route-change payload into a bounded route identity. */
|
|
5
|
+
export function routeFromEvent(event) {
|
|
6
|
+
if (event.type !== EventType.ROUTE_CHANGE)
|
|
7
|
+
return undefined;
|
|
8
|
+
const pathname = event.data['pathname'];
|
|
9
|
+
if ('string' !== typeof pathname || 0 === pathname.length)
|
|
10
|
+
return undefined;
|
|
11
|
+
return pathname;
|
|
12
|
+
}
|
|
13
|
+
/** Read the same route shape from a session's absolute URL. */
|
|
14
|
+
export function routeFromUrl(value) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return url.pathname;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function routesFromEvents(events) {
|
|
24
|
+
return events.flatMap((event) => {
|
|
25
|
+
const route = routeFromEvent(event);
|
|
26
|
+
return route === undefined ? [] : [route];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Persist initial and subsequent routes for existing and future browser sessions. */
|
|
30
|
+
export function attachRouteLearning(bridge, project) {
|
|
31
|
+
const attached = new WeakSet();
|
|
32
|
+
const pending = new Set();
|
|
33
|
+
let flushTimer;
|
|
34
|
+
const flush = () => {
|
|
35
|
+
flushTimer = undefined;
|
|
36
|
+
const routes = [...pending];
|
|
37
|
+
pending.clear();
|
|
38
|
+
if (routes.length > 0)
|
|
39
|
+
void project.recordRoutes(routes).catch(() => undefined);
|
|
40
|
+
};
|
|
41
|
+
const queue = (route) => {
|
|
42
|
+
pending.add(route);
|
|
43
|
+
if (flushTimer !== undefined)
|
|
44
|
+
clearTimeout(flushTimer);
|
|
45
|
+
flushTimer = setTimeout(flush, ROUTE_PERSIST_DEBOUNCE_MS);
|
|
46
|
+
};
|
|
47
|
+
const attach = (session) => {
|
|
48
|
+
if (attached.has(session))
|
|
49
|
+
return;
|
|
50
|
+
attached.add(session);
|
|
51
|
+
const initial = routeFromUrl(session.url);
|
|
52
|
+
if (initial !== undefined)
|
|
53
|
+
queue(initial);
|
|
54
|
+
session.onEvent((event) => {
|
|
55
|
+
const route = routeFromEvent(event);
|
|
56
|
+
if (route !== undefined)
|
|
57
|
+
queue(route);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
for (const session of bridge.sessions.all())
|
|
61
|
+
attach(session);
|
|
62
|
+
bridge.attachSessionReady(attach);
|
|
63
|
+
}
|
|
@@ -26,6 +26,12 @@ export declare class ProjectStore {
|
|
|
26
26
|
* (last PER_NAME of any one name, then cap to TOTAL overall) and writes byte-stably.
|
|
27
27
|
*/
|
|
28
28
|
recordRun(record: Omit<RunRecord, 'at'>): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Add discovered routes to the learned app map. Empty input is a no-op so "nothing observed"
|
|
31
|
+
* does not materialize as `learned.routes: []`; non-empty updates are serialized per file so
|
|
32
|
+
* concurrent browser sessions accumulate rather than overwriting one another.
|
|
33
|
+
*/
|
|
34
|
+
recordRoutes(routes: readonly string[]): Promise<void>;
|
|
29
35
|
/** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
|
|
30
36
|
lastRun(name: string): Promise<RunRecord | undefined>;
|
|
31
37
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PROJECT_FILE_VERSION, PROJECT_RUN_CAP, ProjectFileSchema, ProjectReadError, } from '@reticlehq/core';
|
|
1
|
+
import { PROJECT_FILE_VERSION, PROJECT_ROUTE_CAP, PROJECT_RUN_CAP, ProjectFileSchema, ProjectReadError, } from '@reticlehq/core';
|
|
2
2
|
import { reticleDirPaths } from './reticle-dir.js';
|
|
3
3
|
import { withFileLock } from './file-lock.js';
|
|
4
4
|
const JSON_INDENT = 2;
|
|
@@ -77,6 +77,30 @@ export class ProjectStore {
|
|
|
77
77
|
await this.#fs.writeFile(path, this.#serialize(next));
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Add discovered routes to the learned app map. Empty input is a no-op so "nothing observed"
|
|
82
|
+
* does not materialize as `learned.routes: []`; non-empty updates are serialized per file so
|
|
83
|
+
* concurrent browser sessions accumulate rather than overwriting one another.
|
|
84
|
+
*/
|
|
85
|
+
async recordRoutes(routes) {
|
|
86
|
+
const additions = routes.filter((route) => route.length > 0);
|
|
87
|
+
if (0 === additions.length)
|
|
88
|
+
return;
|
|
89
|
+
const path = reticleDirPaths(this.#root).project;
|
|
90
|
+
await withFileLock(path, async () => {
|
|
91
|
+
const existing = await this.read();
|
|
92
|
+
const base = existing.ok ? existing.file : EMPTY_PROJECT;
|
|
93
|
+
const current = base.learned?.routes ?? [];
|
|
94
|
+
const merged = [...new Set([...current, ...additions])].slice(0, PROJECT_ROUTE_CAP);
|
|
95
|
+
if (merged.length === current.length &&
|
|
96
|
+
merged.every((route, index) => route === current[index]))
|
|
97
|
+
return;
|
|
98
|
+
const learned = { ...base.learned, routes: merged };
|
|
99
|
+
const next = { ...base, learned };
|
|
100
|
+
await this.#fs.mkdir(reticleDirPaths(this.#root).root);
|
|
101
|
+
await this.#fs.writeFile(path, this.#serialize(next));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
80
104
|
/** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
|
|
81
105
|
async lastRun(name) {
|
|
82
106
|
const read = await this.read();
|