@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.
Files changed (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
@@ -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 arg = connectArg(port, projectId);
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(${arg});
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
+ }
@@ -28,4 +28,5 @@ export declare function resetOutageReporting(): void;
28
28
  export declare function reportMcpOutage(stage: OutageStage, facts: {
29
29
  reason: string;
30
30
  attempts: number;
31
+ pendingLost?: number;
31
32
  }): void;
@@ -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: { stage, reason: outageReason(facts.reason), attempts: facts.attempts },
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
  }
@@ -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
- export declare function buildSessionUrl(rawData: string, port: number): string;
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.
@@ -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
- return rawData.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${rawData}` : rawData;
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
- note: 'answered queued requests instead of holding them for a daemon that never arrived',
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
- reportMcpOutage(OutageStage.FIRST, { reason, attempts });
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
- for (const reply of streamLossReplies(pending, reason))
588
+ const lost = streamLossReplies(pending, reason);
589
+ for (const reply of lost)
561
590
  emit(reply);
562
- scheduleReconnect(reason, detail);
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
- const server = new McpServer(SERVER_INFO);
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
- else
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
  });