@reticlehq/server 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +226 -26
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +59 -6
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +74 -14
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/cli/cli-parse.d.ts +5 -9
- package/dist/cli/cli-parse.js +7 -23
- package/dist/cli/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +44 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +170 -19
- package/dist/daemon/daemon-resilience.d.ts +24 -0
- package/dist/daemon/daemon-resilience.js +105 -17
- package/dist/daemon/daemon.js +26 -1
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-shutdown.d.ts +13 -5
- package/dist/daemon/idle-shutdown.js +14 -6
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/events/predicate-eval.d.ts +17 -3
- package/dist/events/predicate-eval.js +183 -96
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +68 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +35 -17
- package/dist/flows/flow-replay.d.ts +1 -1
- package/dist/flows/flow-replay.js +19 -2
- package/dist/flows/flow-step-runners.js +2 -3
- package/dist/flows/flow-tools.js +10 -14
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/honesty/blind-spots.d.ts +19 -0
- package/dist/honesty/blind-spots.js +33 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +42 -3
- package/dist/http-server.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +36 -3
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +28 -5
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +14 -0
- package/dist/init/detect.js +23 -0
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/node-io.js +8 -2
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +55 -3
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +93 -12
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +169 -23
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/mcp/mcp-outage.d.ts +1 -0
- package/dist/mcp/mcp-outage.js +8 -1
- package/dist/mcp/mcp-proxy.d.ts +11 -1
- package/dist/mcp/mcp-proxy.js +39 -9
- package/dist/mcp/mcp.js +64 -2
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +11 -4
- package/dist/session/no-session-diagnosis.js +95 -14
- package/dist/session/no-session-watch.d.ts +3 -1
- package/dist/session/no-session-watch.js +10 -2
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +12 -0
- package/dist/session/session-manager.js +83 -3
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +20 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +25 -7
- package/dist/telemetry/daemon-telemetry.js +32 -13
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/feedback-cli.d.ts +7 -2
- package/dist/telemetry/feedback-cli.js +49 -5
- package/dist/telemetry/feedback-context.d.ts +1 -6
- package/dist/telemetry/feedback-context.js +64 -4
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +4 -4
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +45 -3
- package/dist/telemetry/session-metrics.js +247 -9
- package/dist/telemetry/telemetry.d.ts +21 -1
- package/dist/telemetry/telemetry.js +34 -9
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +63 -14
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +22 -3
- package/dist/tools/browser-tools.js +8 -2
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +26 -0
- package/dist/tools/dynamic-tools.js +6 -1
- package/dist/tools/error-recovery.d.ts +14 -0
- package/dist/tools/error-recovery.js +89 -8
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +76 -7
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +15 -1
- package/dist/tools/navigate-result.js +17 -10
- package/dist/tools/observe-tools.js +22 -4
- package/dist/tools/real-input-attempt.js +10 -5
- package/dist/tools/tool-kit.d.ts +60 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +13 -0
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +15 -3
- package/docs/benchmarks.md +1 -1
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +17 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/local-registry.md +1 -1
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +1 -1
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +15 -3
- package/docs/telemetry.md +16 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
package/dist/init/snippets.js
CHANGED
|
@@ -50,7 +50,7 @@ export function nextReticleDevFile(port, projectId, testids = [], stores = []) {
|
|
|
50
50
|
const fields = '' === base ? '' : `${base.slice(1, -1).trim()}, `;
|
|
51
51
|
const ids = testids.map((t) => `'${t}'`).join(', ');
|
|
52
52
|
const storeBlock = 0 === stores.length
|
|
53
|
-
? ' // No state library detected. If you add one, register it here — see docs/usage.md.'
|
|
53
|
+
? ' // No state library detected. If you add one, register it here — see node_modules/@reticlehq/server/docs/usage.md.'
|
|
54
54
|
: stores.map((h) => ` // import your store, then: ${h}`).join('\n');
|
|
55
55
|
return `'use client';
|
|
56
56
|
import { useEffect } from 'react';
|
|
@@ -174,7 +174,7 @@ Start the daemon BEFORE \`astro dev\`, so the token file exists when the config
|
|
|
174
174
|
export function viteDevModuleFile(testids, stores) {
|
|
175
175
|
const ids = testids.map((t) => `'${t}'`).join(', ');
|
|
176
176
|
const storeBlock = 0 === stores.length
|
|
177
|
-
? ' // No state library detected. If you add one, register it here — see docs/usage.md.'
|
|
177
|
+
? ' // No state library detected. If you add one, register it here — see node_modules/@reticlehq/server/docs/usage.md.'
|
|
178
178
|
: stores.map((h) => ` // import your store, then: ${h}`).join('\n');
|
|
179
179
|
return `// Dev-only. Imported automatically by @reticlehq/vite-plugin — you do not need to import it.
|
|
180
180
|
// Self-guards on import.meta.env.DEV, so it is a no-op in a production build.
|
|
@@ -216,8 +216,22 @@ export const NEXT_LAYOUT_MANUAL = `Mount <ReticleDev /> in your root layout (app
|
|
|
216
216
|
* where a bare `@reticlehq/react` import resolves. A bare import in a plain index.html does NOT resolve in
|
|
217
217
|
* the browser, so we never tell a bundled app to do that (the old advice silently failed for CRA).
|
|
218
218
|
*/
|
|
219
|
-
export function htmlManual(port, projectId) {
|
|
220
|
-
const
|
|
219
|
+
export function htmlManual(port, projectId, pairingToken) {
|
|
220
|
+
const base = connectArg(port, projectId);
|
|
221
|
+
// The token belongs INSIDE the call the user pastes. Every other stack has a build step to inline
|
|
222
|
+
// it (the Vite plugin's `define`, Next's NEXT_PUBLIC_*, Astro's config, CRA's .env); this path has
|
|
223
|
+
// none, so `init` inlines the literal it already read. Without it the bridge closes the socket
|
|
224
|
+
// with AUTH_FAILED and no session ever appears — see Bridge's hello handler.
|
|
225
|
+
const withToken = pairingToken === undefined || 0 === pairingToken.length
|
|
226
|
+
? base
|
|
227
|
+
: `{ ${[base.length > 0 ? base.slice(1, -1).trim() : '', `token: '${pairingToken}'`]
|
|
228
|
+
.filter((p) => p.length > 0)
|
|
229
|
+
.join(', ')} }`;
|
|
230
|
+
const tokenNote = pairingToken === undefined || 0 === pairingToken.length
|
|
231
|
+
? ''
|
|
232
|
+
: `\n\n The \`token\` is this machine's pairing token, read from ~/.reticle/pairing-token. Keep it: the
|
|
233
|
+
bridge REJECTS a connect without it ("authentication failed") and no session appears. It is
|
|
234
|
+
per-machine and local-only, so do not commit it — a teammate's daemon mints their own.`;
|
|
221
235
|
return `No Vite/Next plugin detected — wire the dev-only connect by hand. Pick the form for your setup:
|
|
222
236
|
|
|
223
237
|
• Bundled app (Create React App, webpack, Parcel, Vue/Svelte CLI, etc.) — add to your ENTRY module
|
|
@@ -226,9 +240,9 @@ export function htmlManual(port, projectId) {
|
|
|
226
240
|
if (window.location.hostname === 'localhost') {
|
|
227
241
|
void import('@reticlehq/react').then(({ reticle, install }) => {
|
|
228
242
|
install();
|
|
229
|
-
reticle.connect(${
|
|
243
|
+
reticle.connect(${withToken});
|
|
230
244
|
});
|
|
231
|
-
}
|
|
245
|
+
}${tokenNote}
|
|
232
246
|
|
|
233
247
|
• Plain static HTML with no build step — the browser can't resolve the bare '@reticlehq/react' import, so
|
|
234
248
|
bundle the SDK once (e.g. \`npx esbuild\`) and point a dev-only <script type="module"> at the output,
|
|
@@ -282,6 +296,57 @@ declare const __RETICLE_ROOT__: string | undefined;
|
|
|
282
296
|
declare const __RETICLE_SDK_VERSION__: string | undefined;
|
|
283
297
|
`;
|
|
284
298
|
}
|
|
299
|
+
/** Where a Nuxt dev-only client plugin belongs. `.client` keeps it out of SSR; Nuxt auto-registers it. */
|
|
300
|
+
export const NUXT_PLUGIN_PATH = 'app/plugins/reticle.client.ts';
|
|
301
|
+
/**
|
|
302
|
+
* The Nuxt recipe, written out in full because every trap in it is one somebody actually hit.
|
|
303
|
+
*
|
|
304
|
+
* Reported from the field, in the order they were hit: `init` classified a Nuxt 4 app as `html`, so
|
|
305
|
+
* it installed a package named `@reticlehq/react` (with `react` in its peer dependencies) into a Vue
|
|
306
|
+
* codebase — the reporter only continued after auditing our dist to confirm there are no React
|
|
307
|
+
* imports at runtime, which most people will not do. It then handed over a snippet guarded on
|
|
308
|
+
* `window.location.hostname === 'localhost'`, which fails twice over in Nuxt: `window` does not
|
|
309
|
+
* exist during SSR, and the dev host here was a hosts-file alias (required for the backend's
|
|
310
|
+
* white-label origin detection), so the guard was false and the connect never ran — no error, no log
|
|
311
|
+
* line, nothing to debug. And nothing said a running dev server does not pick up a new plugin.
|
|
312
|
+
*
|
|
313
|
+
* So: `import.meta.dev` (build-time, host-independent) instead of a hostname check, `.client.ts`
|
|
314
|
+
* instead of an SSR guard, the framework-neutral sensor instead of the React kit, the non-localhost
|
|
315
|
+
* flag named up front, and the restart said out loud.
|
|
316
|
+
*/
|
|
317
|
+
export function nuxtManual(port, projectId) {
|
|
318
|
+
const base = connectArg(port, projectId);
|
|
319
|
+
const fields = '' === base ? '' : base.slice(1, -1).trim();
|
|
320
|
+
const connect = '' === fields ? 'reticle.connect()' : `reticle.connect({ ${fields} })`;
|
|
321
|
+
return `Nuxt owns its own Vite instance and renders its own HTML, so there is no vite.config to patch
|
|
322
|
+
and no index.html to inject into. Wire it with a dev-only CLIENT plugin, which is Nuxt's own idiom:
|
|
323
|
+
|
|
324
|
+
1. Create ${NUXT_PLUGIN_PATH}:
|
|
325
|
+
|
|
326
|
+
export default defineNuxtPlugin(() => {
|
|
327
|
+
// import.meta.dev is the correct guard: it is resolved at build time, so it does not care
|
|
328
|
+
// what hostname you develop on. Do NOT guard on window.location.hostname === 'localhost' —
|
|
329
|
+
// that is false on any hosts-file alias or LAN address, and window does not exist in SSR.
|
|
330
|
+
if (!import.meta.dev) return
|
|
331
|
+
void import('@reticlehq/browser').then(({ reticle }) => {
|
|
332
|
+
${connect}
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
The .client.ts suffix is load-bearing: it is what keeps this out of the server bundle.
|
|
337
|
+
|
|
338
|
+
2. Restart the dev server. A dev server that is already running does not pick up a new plugin —
|
|
339
|
+
it will not appear in .nuxt/plugins/client.mjs, and the app will come up with no SDK at all.
|
|
340
|
+
|
|
341
|
+
3. If your dev host is anything other than localhost (a hosts-file alias, a LAN IP, a tunnel), add
|
|
342
|
+
allowNonLocalhost: true to that connect call. Without it the SDK loads and then refuses, and the
|
|
343
|
+
only sign is one line in the browser console.
|
|
344
|
+
|
|
345
|
+
The package is @reticlehq/browser — the framework-neutral sensor. DOM, network, console, routing and
|
|
346
|
+
source file:line all work in Vue. What you do not get is React component identity, which is the only
|
|
347
|
+
thing the React adapter adds. There is no Nuxt app in this project's CI, so this path is UNVERIFIED:
|
|
348
|
+
if something does not work, please open an issue.`;
|
|
349
|
+
}
|
|
285
350
|
/**
|
|
286
351
|
* Root-level project config for Reticle. Written by `reticle init`; read by `reticle mcp` for the port
|
|
287
352
|
* and by tooling for the stable projectId (the app's identity across port changes).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote one argument for a Windows command line. Pure, and deliberately NOT platform-gated.
|
|
3
|
+
*
|
|
4
|
+
* The rule used to live inside a `process.platform === WINDOWS` branch in node-io.ts, which meant
|
|
5
|
+
* the Windows path could not be exercised on a mac or in CI's Linux job — so it stayed wrong. A
|
|
6
|
+
* pure function is most of the fix: it can be asserted anywhere, and it is.
|
|
7
|
+
*
|
|
8
|
+
* ## What was wrong
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Two independent holes, both reachable from an ordinary directory path:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Backslashes were not escaped.** `C:\Users\ada\My Projects\` became
|
|
17
|
+
* `"C:\Users\ada\My Projects\"`, whose trailing `\"` reads as an ESCAPED quote — the quoted
|
|
18
|
+
* region never closes and the rest of the line is re-parsed by cmd.exe. CodeQL flagged this one
|
|
19
|
+
* (`js/incomplete-sanitization`, high).
|
|
20
|
+
* 2. **The predicate only looked for whitespace and quotes.** An argument like `foo&whoami` has
|
|
21
|
+
* neither, so it was handed to cmd.exe completely unquoted. Correct backslash escaping does
|
|
22
|
+
* nothing for that; it needs the metacharacters in the trigger.
|
|
23
|
+
*
|
|
24
|
+
* ## The rule implemented here
|
|
25
|
+
*
|
|
26
|
+
* `CommandLineToArgvW`: a run of backslashes is literal unless it precedes a `"`, in which case the
|
|
27
|
+
* run is doubled and the quote escaped; a run at the very end sits before the closing quote we add,
|
|
28
|
+
* so it is doubled too. Correctly-terminated quotes also neutralise cmd.exe's metacharacters, which
|
|
29
|
+
* it does not interpret inside a quoted region.
|
|
30
|
+
*
|
|
31
|
+
* ## Known limit, stated rather than papered over
|
|
32
|
+
*
|
|
33
|
+
* `%VAR%` is expanded by cmd.exe even inside quotes, and no quoting prevents it — only avoiding
|
|
34
|
+
* `shell: true` does, which Windows needs so `pnpm.cmd`/`npx.cmd` resolve (see `shellOpt`). That is
|
|
35
|
+
* variable substitution, not command execution, and every caller here passes paths and package
|
|
36
|
+
* names rather than user prose.
|
|
37
|
+
*/
|
|
38
|
+
export declare function windowsShellArg(arg: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote one argument for a Windows command line. Pure, and deliberately NOT platform-gated.
|
|
3
|
+
*
|
|
4
|
+
* The rule used to live inside a `process.platform === WINDOWS` branch in node-io.ts, which meant
|
|
5
|
+
* the Windows path could not be exercised on a mac or in CI's Linux job — so it stayed wrong. A
|
|
6
|
+
* pure function is most of the fix: it can be asserted anywhere, and it is.
|
|
7
|
+
*
|
|
8
|
+
* ## What was wrong
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Two independent holes, both reachable from an ordinary directory path:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Backslashes were not escaped.** `C:\Users\ada\My Projects\` became
|
|
17
|
+
* `"C:\Users\ada\My Projects\"`, whose trailing `\"` reads as an ESCAPED quote — the quoted
|
|
18
|
+
* region never closes and the rest of the line is re-parsed by cmd.exe. CodeQL flagged this one
|
|
19
|
+
* (`js/incomplete-sanitization`, high).
|
|
20
|
+
* 2. **The predicate only looked for whitespace and quotes.** An argument like `foo&whoami` has
|
|
21
|
+
* neither, so it was handed to cmd.exe completely unquoted. Correct backslash escaping does
|
|
22
|
+
* nothing for that; it needs the metacharacters in the trigger.
|
|
23
|
+
*
|
|
24
|
+
* ## The rule implemented here
|
|
25
|
+
*
|
|
26
|
+
* `CommandLineToArgvW`: a run of backslashes is literal unless it precedes a `"`, in which case the
|
|
27
|
+
* run is doubled and the quote escaped; a run at the very end sits before the closing quote we add,
|
|
28
|
+
* so it is doubled too. Correctly-terminated quotes also neutralise cmd.exe's metacharacters, which
|
|
29
|
+
* it does not interpret inside a quoted region.
|
|
30
|
+
*
|
|
31
|
+
* ## Known limit, stated rather than papered over
|
|
32
|
+
*
|
|
33
|
+
* `%VAR%` is expanded by cmd.exe even inside quotes, and no quoting prevents it — only avoiding
|
|
34
|
+
* `shell: true` does, which Windows needs so `pnpm.cmd`/`npx.cmd` resolve (see `shellOpt`). That is
|
|
35
|
+
* variable substitution, not command execution, and every caller here passes paths and package
|
|
36
|
+
* names rather than user prose.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Characters that make cmd.exe do something other than pass the text along.
|
|
40
|
+
*
|
|
41
|
+
* Whitespace and `"` split or delimit; the rest are the shell's control operators. An argument
|
|
42
|
+
* containing any of them must be quoted, not just one containing a space.
|
|
43
|
+
*/
|
|
44
|
+
const NEEDS_QUOTING = /[\s"&|<>^()!%,;=]/;
|
|
45
|
+
export function windowsShellArg(arg) {
|
|
46
|
+
// An empty argument must still occupy a slot in argv. Unquoted it disappears entirely, silently
|
|
47
|
+
// shifting every argument after it by one.
|
|
48
|
+
if (0 === arg.length)
|
|
49
|
+
return '""';
|
|
50
|
+
if (!NEEDS_QUOTING.test(arg))
|
|
51
|
+
return arg;
|
|
52
|
+
let out = '"';
|
|
53
|
+
let backslashes = 0;
|
|
54
|
+
for (const ch of arg) {
|
|
55
|
+
if ('\\' === ch) {
|
|
56
|
+
backslashes += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if ('"' === ch) {
|
|
60
|
+
// 2n+1 backslashes → n literal backslashes and an escaped quote.
|
|
61
|
+
out += '\\'.repeat(2 * backslashes + 1) + '"';
|
|
62
|
+
backslashes = 0;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out += '\\'.repeat(backslashes) + ch;
|
|
66
|
+
backslashes = 0;
|
|
67
|
+
}
|
|
68
|
+
// A trailing run sits immediately before the closing quote, so it must be doubled or it escapes it.
|
|
69
|
+
return `${out}${'\\'.repeat(2 * backslashes)}"`;
|
|
70
|
+
}
|
package/dist/mcp/mcp-outage.d.ts
CHANGED
package/dist/mcp/mcp-outage.js
CHANGED
|
@@ -43,6 +43,13 @@ export function reportMcpOutage(stage, facts) {
|
|
|
43
43
|
return;
|
|
44
44
|
reported.add(stage);
|
|
45
45
|
void getTelemetry().emit(TelemetryEventKind.MCP_CONNECTION_LOST, {
|
|
46
|
-
outage: {
|
|
46
|
+
outage: {
|
|
47
|
+
stage,
|
|
48
|
+
reason: outageReason(facts.reason),
|
|
49
|
+
attempts: facts.attempts,
|
|
50
|
+
// Always sent, including zero: zero is the finding — a drop no agent could feel. See
|
|
51
|
+
// McpOutageSchema.pendingLost.
|
|
52
|
+
pendingLost: facts.pendingLost ?? 0,
|
|
53
|
+
},
|
|
47
54
|
});
|
|
48
55
|
}
|
package/dist/mcp/mcp-proxy.d.ts
CHANGED
|
@@ -104,7 +104,17 @@ export declare class SseFrameParser {
|
|
|
104
104
|
export declare function probeDaemon(port: number): Promise<boolean>;
|
|
105
105
|
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
106
106
|
export declare function waitForDaemon(port: number): Promise<void>;
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
|
|
109
|
+
*
|
|
110
|
+
* An empty (or whitespace) data field is the shape a malformed daemon response takes, and it used to
|
|
111
|
+
* be accepted: `postUrl` became `''`, which is not `null`, so the proxy considered itself CONNECTED
|
|
112
|
+
* and posted every subsequent message to the empty string. Each one failed, the queue never armed
|
|
113
|
+
* its deadline because nothing was ever queued, and the agent held a session that could not carry a
|
|
114
|
+
* single call. Refusing the frame keeps the proxy in its disconnected path, which already knows how
|
|
115
|
+
* to reconnect and how to answer what it cannot send.
|
|
116
|
+
*/
|
|
117
|
+
export declare function buildSessionUrl(rawData: string, port: number): string | null;
|
|
108
118
|
/**
|
|
109
119
|
* Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
|
|
110
120
|
* Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
|
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
9
9
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
10
10
|
import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
|
|
11
|
-
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
11
|
+
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, onQueueExpired, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
12
12
|
import { log } from '../log.js';
|
|
13
13
|
import { OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
14
14
|
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
@@ -373,8 +373,21 @@ function postToSession(url, body) {
|
|
|
373
373
|
req.end();
|
|
374
374
|
});
|
|
375
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
|
|
378
|
+
*
|
|
379
|
+
* An empty (or whitespace) data field is the shape a malformed daemon response takes, and it used to
|
|
380
|
+
* be accepted: `postUrl` became `''`, which is not `null`, so the proxy considered itself CONNECTED
|
|
381
|
+
* and posted every subsequent message to the empty string. Each one failed, the queue never armed
|
|
382
|
+
* its deadline because nothing was ever queued, and the agent held a session that could not carry a
|
|
383
|
+
* single call. Refusing the frame keeps the proxy in its disconnected path, which already knows how
|
|
384
|
+
* to reconnect and how to answer what it cannot send.
|
|
385
|
+
*/
|
|
376
386
|
export function buildSessionUrl(rawData, port) {
|
|
377
|
-
|
|
387
|
+
const trimmed = rawData.trim();
|
|
388
|
+
if ('' === trimmed)
|
|
389
|
+
return null;
|
|
390
|
+
return trimmed.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${trimmed}` : trimmed;
|
|
378
391
|
}
|
|
379
392
|
/**
|
|
380
393
|
* Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
|
|
@@ -428,9 +441,16 @@ ensureDaemon) {
|
|
|
428
441
|
stdinQueue.splice(0);
|
|
429
442
|
for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
|
|
430
443
|
emit(reply);
|
|
444
|
+
// The expiry is the proof that the reconnect we believed was in flight is not coming — a
|
|
445
|
+
// wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
|
|
446
|
+
// rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
|
|
447
|
+
// NEXT request re-probe the port instead of queueing behind a reconnect that will never
|
|
448
|
+
// land, and it is the only path by which a daemon started AFTER we gave up is ever found.
|
|
449
|
+
dormant = onQueueExpired() === OnDrop.DORMANT;
|
|
431
450
|
proxyLog('reticle_mcp_proxy_queue_expired', {
|
|
432
451
|
port,
|
|
433
|
-
|
|
452
|
+
dormant,
|
|
453
|
+
note: 'answered queued requests and went dormant; the next client request re-probes the port',
|
|
434
454
|
});
|
|
435
455
|
}, QUEUE_WAIT_MS);
|
|
436
456
|
queueTimer.unref();
|
|
@@ -469,6 +489,13 @@ ensureDaemon) {
|
|
|
469
489
|
function onSseEvent(event, data, p) {
|
|
470
490
|
if ('endpoint' === event) {
|
|
471
491
|
const url = buildSessionUrl(data, p);
|
|
492
|
+
if (null === url) {
|
|
493
|
+
proxyLog('reticle_mcp_proxy_empty_endpoint', {
|
|
494
|
+
port,
|
|
495
|
+
note: 'endpoint frame carried no URL; staying disconnected rather than posting to nowhere',
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
472
499
|
postUrl = url;
|
|
473
500
|
// A stream that reached `endpoint` is a USABLE session — that, not a set of response
|
|
474
501
|
// headers, is what earns a fresh retry budget. Resetting on headers let a daemon that
|
|
@@ -491,18 +518,19 @@ ensureDaemon) {
|
|
|
491
518
|
emit(data);
|
|
492
519
|
}
|
|
493
520
|
}
|
|
494
|
-
function scheduleReconnect(reason, detail) {
|
|
521
|
+
function scheduleReconnect(reason, detail, pendingLost = 0) {
|
|
495
522
|
if (stopped)
|
|
496
523
|
return;
|
|
497
524
|
postUrl = null;
|
|
498
525
|
attempts++;
|
|
499
526
|
// Once per process: this session has now lost its tools at least once, which is the number
|
|
500
|
-
// that says whether the transport work actually landed for real users.
|
|
501
|
-
|
|
527
|
+
// that says whether the transport work actually landed for real users. `pendingLost` is the
|
|
528
|
+
// part an agent can FEEL — almost every measured drop killed nothing in flight.
|
|
529
|
+
reportMcpOutage(OutageStage.FIRST, { reason, attempts, pendingLost });
|
|
502
530
|
// The severe stage: we are about to stop retrying. Reported so the share of sessions where
|
|
503
531
|
// MCP never came back on its own is a number rather than an anecdote.
|
|
504
532
|
if (attempts > MAX_RECONNECT_ATTEMPTS) {
|
|
505
|
-
reportMcpOutage(OutageStage.BUDGET_SPENT, { reason, attempts });
|
|
533
|
+
reportMcpOutage(OutageStage.BUDGET_SPENT, { reason, attempts, pendingLost });
|
|
506
534
|
// Stop RETRYING, never stop SERVING. See onReconnectBudgetSpent: exiting here made the
|
|
507
535
|
// client mark the MCP server disconnected and left a human to reconnect it by hand.
|
|
508
536
|
dormant = onReconnectBudgetSpent() === OnDrop.DORMANT;
|
|
@@ -557,9 +585,11 @@ ensureDaemon) {
|
|
|
557
585
|
settled = true;
|
|
558
586
|
// Answer anything the dead session owed BEFORE reconnecting. The new session cannot know
|
|
559
587
|
// about it, so silence here is permanent.
|
|
560
|
-
|
|
588
|
+
const lost = streamLossReplies(pending, reason);
|
|
589
|
+
for (const reply of lost)
|
|
561
590
|
emit(reply);
|
|
562
|
-
|
|
591
|
+
// How many calls this drop actually killed. Zero means the agent could not feel it.
|
|
592
|
+
scheduleReconnect(reason, detail, lost.length);
|
|
563
593
|
};
|
|
564
594
|
const sse = new SseFrameParser();
|
|
565
595
|
res.on('data', (chunk) => {
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -18,6 +18,7 @@ import { SERVER_VERSION } from '../version/server-version.js';
|
|
|
18
18
|
import { MCP_SERVER_NAME } from '../init/mcp.js';
|
|
19
19
|
import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
|
|
20
20
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
21
|
+
import { parsePredicate } from '../events/predicate-parse.js';
|
|
21
22
|
/**
|
|
22
23
|
* Merge the runtime-spliced envelope (health/lease/age/control) into a session-bound tool's declared
|
|
23
24
|
* outputSchema so structuredContent validation keeps those fields instead of dropping them. The tool's
|
|
@@ -30,6 +31,27 @@ export function withSessionEnvelope(name, outputSchema) {
|
|
|
30
31
|
return { ...sessionEnvelopeShape, ...outputSchema };
|
|
31
32
|
}
|
|
32
33
|
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.`;
|
|
33
55
|
/** First sentence of a description (purpose only) for lean profiles — keeps per-turn def cost
|
|
34
56
|
* down. Cuts at the first sentence-ending period or newline; falls back to a 160-char cap. The
|
|
35
57
|
* first clause retains the essentials (enum hints like "role | text | label" live there). */
|
|
@@ -251,6 +273,17 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
|
|
|
251
273
|
// McpError.message already carries "MCP error -32602: "; re-wrapping would print it twice.
|
|
252
274
|
const raw = error instanceof Error ? error.message : String(error);
|
|
253
275
|
const detail = raw.replace(/^MCP error -?\d+:\s*/, '');
|
|
276
|
+
// A malformed PREDICATE gets the sentence, not the array. The SDK validates tool input against
|
|
277
|
+
// the same zod schema and throws a serialized issue list — so the readable message
|
|
278
|
+
// `parsePredicate` exists to produce was being generated only on paths that never run over
|
|
279
|
+
// MCP, which is every path a real agent uses. Found by driving a live app: `reticle_assert`
|
|
280
|
+
// answered a near-miss predicate with a raw zod array naming the unknown keys and not one
|
|
281
|
+
// field that would have worked. It already carries its own guidance, so the generic example is
|
|
282
|
+
// dropped rather than argued with.
|
|
283
|
+
const predicateHelp = predicateMessage(args);
|
|
284
|
+
if (predicateHelp !== undefined) {
|
|
285
|
+
throw new McpError(ErrorCode.InvalidParams, predicateHelp);
|
|
286
|
+
}
|
|
254
287
|
const example = examples.get(toolName);
|
|
255
288
|
const help = example === undefined
|
|
256
289
|
? ` Call reticle_tools { names: ["${toolName}"] } for its parameters.`
|
|
@@ -259,6 +292,29 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
|
|
|
259
292
|
}
|
|
260
293
|
};
|
|
261
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* The readable reason a predicate argument did not parse, or undefined when the predicate is fine.
|
|
297
|
+
*
|
|
298
|
+
* Undefined matters as much as the message: a call can fail validation for a reason that has nothing
|
|
299
|
+
* to do with the predicate (a bad `timeout_ms`, a missing `ref`), and replacing that error with a
|
|
300
|
+
* predicate explanation would be confidently wrong. Re-parsing is the honest test — if it parses,
|
|
301
|
+
* the predicate is not what broke.
|
|
302
|
+
*/
|
|
303
|
+
function predicateMessage(args) {
|
|
304
|
+
if (typeof args !== 'object' || null === args)
|
|
305
|
+
return undefined;
|
|
306
|
+
const record = args;
|
|
307
|
+
const predicate = record['predicate'] ?? record['until'];
|
|
308
|
+
if (predicate === undefined)
|
|
309
|
+
return undefined;
|
|
310
|
+
try {
|
|
311
|
+
parsePredicate(predicate);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return error instanceof Error ? error.message : undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
262
318
|
/**
|
|
263
319
|
* Answer a call for a real-but-un-advertised tool with the way to make it work.
|
|
264
320
|
*
|
|
@@ -292,7 +348,11 @@ function toolNameOf(request) {
|
|
|
292
348
|
}
|
|
293
349
|
export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
|
|
294
350
|
const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
|
|
295
|
-
|
|
351
|
+
// Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
|
|
352
|
+
// different products from inside an agent's context, so outcomes are only comparable when the
|
|
353
|
+
// session says which one it saw.
|
|
354
|
+
getSessionMetrics().recordSurface(profile);
|
|
355
|
+
const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
|
|
296
356
|
// Which agent is on the other end, taken from its own `initialize` handshake. Registered as a lazy
|
|
297
357
|
// hook rather than read here: the handshake has not happened yet at construction time, and a
|
|
298
358
|
// feedback report filed twenty tool calls later is exactly when we want the answer.
|
|
@@ -300,8 +360,10 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
|
|
|
300
360
|
const info = server.server.getClientVersion();
|
|
301
361
|
// Record it as a side effect of the first read so the session summary can report WHICH agents
|
|
302
362
|
// drove this daemon — the multi-agent story, visible without a second plumbing path.
|
|
363
|
+
// Name AND version: the version was already in this handshake and was being dropped, so a
|
|
364
|
+
// regression in agent behaviour could never be attributed to a client build.
|
|
303
365
|
if (info?.name !== undefined)
|
|
304
|
-
getSessionMetrics().recordClient(info.name);
|
|
366
|
+
getSessionMetrics().recordClient(info.name, info.version);
|
|
305
367
|
return info === undefined ? undefined : { name: info.name, version: info.version };
|
|
306
368
|
});
|
|
307
369
|
// Cast once to our bridge type so every per-tool call site is typed without `any`.
|
|
@@ -44,6 +44,25 @@ export declare function onStreamDrop(daemonListening: boolean): OnDrop;
|
|
|
44
44
|
* whether the server survives.
|
|
45
45
|
*/
|
|
46
46
|
export declare function onReconnectBudgetSpent(): OnDrop;
|
|
47
|
+
/**
|
|
48
|
+
* What to do when queued requests expire with nothing connected. Always DORMANT.
|
|
49
|
+
*
|
|
50
|
+
* This is the hole that produced the worst report we have had: a first-run daemon came up wedged, the
|
|
51
|
+
* proxy spent its budget against it, and from then on every call returned −32001 — including after
|
|
52
|
+
* the human killed the wedged process and started a healthy daemon on the same port. The proxy never
|
|
53
|
+
* looked again.
|
|
54
|
+
*
|
|
55
|
+
* The cause is a state that lies. `WAKE` clears `dormant` and starts a reconnect; if that reconnect
|
|
56
|
+
* neither resolves nor rejects (exactly what a wedged port does — it accepts the socket and never
|
|
57
|
+
* serves SSE), `dormant` stays false forever. Every later request then reads as "a reconnect is
|
|
58
|
+
* already coming" and QUEUEs, the queue expires, and the request is answered with a failure. Nothing
|
|
59
|
+
* in that loop ever re-probes the port, so a daemon that appears afterwards is invisible.
|
|
60
|
+
*
|
|
61
|
+
* Expiring the queue is the proof that the in-flight reconnect is not coming. Going dormant makes the
|
|
62
|
+
* NEXT client request wake and re-probe — which is the only thing that can discover a daemon that
|
|
63
|
+
* arrived late, and it costs nothing when one has not.
|
|
64
|
+
*/
|
|
65
|
+
export declare function onQueueExpired(): OnDrop;
|
|
47
66
|
/** What to do with a client message. */
|
|
48
67
|
export declare const OnRequest: {
|
|
49
68
|
/** Connected — post it straight through. */
|
|
@@ -47,6 +47,27 @@ export function onStreamDrop(daemonListening) {
|
|
|
47
47
|
export function onReconnectBudgetSpent() {
|
|
48
48
|
return OnDrop.DORMANT;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* What to do when queued requests expire with nothing connected. Always DORMANT.
|
|
52
|
+
*
|
|
53
|
+
* This is the hole that produced the worst report we have had: a first-run daemon came up wedged, the
|
|
54
|
+
* proxy spent its budget against it, and from then on every call returned −32001 — including after
|
|
55
|
+
* the human killed the wedged process and started a healthy daemon on the same port. The proxy never
|
|
56
|
+
* looked again.
|
|
57
|
+
*
|
|
58
|
+
* The cause is a state that lies. `WAKE` clears `dormant` and starts a reconnect; if that reconnect
|
|
59
|
+
* neither resolves nor rejects (exactly what a wedged port does — it accepts the socket and never
|
|
60
|
+
* serves SSE), `dormant` stays false forever. Every later request then reads as "a reconnect is
|
|
61
|
+
* already coming" and QUEUEs, the queue expires, and the request is answered with a failure. Nothing
|
|
62
|
+
* in that loop ever re-probes the port, so a daemon that appears afterwards is invisible.
|
|
63
|
+
*
|
|
64
|
+
* Expiring the queue is the proof that the in-flight reconnect is not coming. Going dormant makes the
|
|
65
|
+
* NEXT client request wake and re-probe — which is the only thing that can discover a daemon that
|
|
66
|
+
* arrived late, and it costs nothing when one has not.
|
|
67
|
+
*/
|
|
68
|
+
export function onQueueExpired() {
|
|
69
|
+
return OnDrop.DORMANT;
|
|
70
|
+
}
|
|
50
71
|
/** What to do with a client message. */
|
|
51
72
|
export const OnRequest = {
|
|
52
73
|
/** Connected — post it straight through. */
|
|
@@ -77,6 +77,15 @@ export declare class BrowserPool {
|
|
|
77
77
|
* forever. Returns the released sessionIds. This is the fault-tolerance backstop for the pool.
|
|
78
78
|
*/
|
|
79
79
|
sweepExpired(): Promise<string[]>;
|
|
80
|
+
/**
|
|
81
|
+
* How many leases this pool has reclaimed for going stale.
|
|
82
|
+
*
|
|
83
|
+
* Read by the no-session diagnosis: an aged-out lease used to be reported as "the tab was closed
|
|
84
|
+
* … ask the human to reopen the app", which is wrong on every clause and sent one reporter looking
|
|
85
|
+
* for a port mismatch (#157). A count is enough — the message says "likeliest", not "certainly",
|
|
86
|
+
* because nothing here knows WHICH session the caller just lost.
|
|
87
|
+
*/
|
|
88
|
+
reapedLeaseCount(): number;
|
|
80
89
|
/**
|
|
81
90
|
* Lease an isolated context, navigate it to `url`, and return a handle. If the pool is at capacity,
|
|
82
91
|
* waits FIFO until a slot frees (or until `signal` aborts, if provided).
|
|
@@ -27,6 +27,8 @@ export class BrowserPool {
|
|
|
27
27
|
#browser;
|
|
28
28
|
#launching;
|
|
29
29
|
#closed = false;
|
|
30
|
+
/** Leases reclaimed for going stale — see reapedLeaseCount(). */
|
|
31
|
+
#reapedLeases = 0;
|
|
30
32
|
#active = new Map();
|
|
31
33
|
/**
|
|
32
34
|
* Slots claimed-or-active — the real concurrency gate. Incremented SYNCHRONOUSLY the instant an
|
|
@@ -85,8 +87,20 @@ export class BrowserPool {
|
|
|
85
87
|
.map(([sessionId]) => sessionId);
|
|
86
88
|
for (const sessionId of expired)
|
|
87
89
|
await this.#release(sessionId);
|
|
90
|
+
this.#reapedLeases += expired.length;
|
|
88
91
|
return expired;
|
|
89
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* How many leases this pool has reclaimed for going stale.
|
|
95
|
+
*
|
|
96
|
+
* Read by the no-session diagnosis: an aged-out lease used to be reported as "the tab was closed
|
|
97
|
+
* … ask the human to reopen the app", which is wrong on every clause and sent one reporter looking
|
|
98
|
+
* for a port mismatch (#157). A count is enough — the message says "likeliest", not "certainly",
|
|
99
|
+
* because nothing here knows WHICH session the caller just lost.
|
|
100
|
+
*/
|
|
101
|
+
reapedLeaseCount() {
|
|
102
|
+
return this.#reapedLeases;
|
|
103
|
+
}
|
|
90
104
|
/**
|
|
91
105
|
* Lease an isolated context, navigate it to `url`, and return a handle. If the pool is at capacity,
|
|
92
106
|
* waits FIFO until a slot frees (or until `signal` aborts, if provided).
|
|
@@ -138,6 +152,15 @@ export class BrowserPool {
|
|
|
138
152
|
this.#active.clear();
|
|
139
153
|
this.#occupied = 0;
|
|
140
154
|
await Promise.all(contexts.map((c) => c.close().catch(() => undefined)));
|
|
155
|
+
// If a launch is in flight, await it so we can close the resulting browser — otherwise it
|
|
156
|
+
// resolves after shutdown returns and the Chromium process is orphaned (hundreds of MB leaked).
|
|
157
|
+
const inflight = this.#launching;
|
|
158
|
+
if (inflight !== undefined) {
|
|
159
|
+
const launched = await inflight.catch(() => undefined);
|
|
160
|
+
if (launched !== undefined && launched !== this.#browser) {
|
|
161
|
+
await launched.close().catch(() => undefined);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
141
164
|
const browser = this.#browser;
|
|
142
165
|
this.#browser = undefined;
|
|
143
166
|
if (browser !== undefined)
|
|
@@ -168,27 +191,33 @@ export class BrowserPool {
|
|
|
168
191
|
const onFree = () => {
|
|
169
192
|
// The pool shut down while we waited → reject rather than relaunch a browser.
|
|
170
193
|
if (this.#closed) {
|
|
194
|
+
if (signal !== undefined)
|
|
195
|
+
signal.removeEventListener('abort', onAbort);
|
|
171
196
|
reject(new Error('browser pool is shut down'));
|
|
172
197
|
return;
|
|
173
198
|
}
|
|
174
199
|
// Claim the freed slot; if another woken waiter beat us to it, re-queue.
|
|
175
|
-
if (this.#tryClaim())
|
|
200
|
+
if (this.#tryClaim()) {
|
|
201
|
+
if (signal !== undefined)
|
|
202
|
+
signal.removeEventListener('abort', onAbort);
|
|
176
203
|
resolve();
|
|
177
|
-
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
178
206
|
this.#waiters.push(onFree);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const onAbort = () => {
|
|
210
|
+
const i = this.#waiters.indexOf(onFree);
|
|
211
|
+
if (i >= 0)
|
|
212
|
+
this.#waiters.splice(i, 1);
|
|
213
|
+
reject(new Error('acquire aborted'));
|
|
179
214
|
};
|
|
180
215
|
if (signal !== undefined) {
|
|
181
216
|
if (signal.aborted) {
|
|
182
217
|
reject(new Error('acquire aborted'));
|
|
183
218
|
return;
|
|
184
219
|
}
|
|
185
|
-
signal.addEventListener('abort',
|
|
186
|
-
// Remove ourselves from the queue so a later wake doesn't claim a slot we'll never use.
|
|
187
|
-
const i = this.#waiters.indexOf(onFree);
|
|
188
|
-
if (i >= 0)
|
|
189
|
-
this.#waiters.splice(i, 1);
|
|
190
|
-
reject(new Error('acquire aborted'));
|
|
191
|
-
}, { once: true });
|
|
220
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
192
221
|
}
|
|
193
222
|
this.#waiters.push(onFree);
|
|
194
223
|
});
|