@reticlehq/server 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +251 -33
  3. package/dist/bridge/bridge.d.ts +6 -1
  4. package/dist/bridge/bridge.js +27 -4
  5. package/dist/capsule/causal-summary.d.ts +20 -0
  6. package/dist/capsule/causal-summary.js +31 -6
  7. package/dist/capsule/divergence.js +7 -1
  8. package/dist/cli/chromium-hint.d.ts +68 -0
  9. package/dist/cli/chromium-hint.js +145 -0
  10. package/dist/cli/cli-doctor.js +31 -36
  11. package/dist/cli/cli-kill.d.ts +58 -0
  12. package/dist/cli/cli-kill.js +162 -0
  13. package/dist/cli/cli-launch.d.ts +18 -0
  14. package/dist/cli/cli-launch.js +32 -1
  15. package/dist/cli/cli-parse.d.ts +16 -1
  16. package/dist/cli/cli-parse.js +32 -9
  17. package/dist/cli/cli-port.d.ts +8 -0
  18. package/dist/cli/cli-port.js +36 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/doctor-daemon-line.js +10 -1
  21. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  22. package/dist/cli/doctor-sessions-line.js +66 -0
  23. package/dist/cli/port-holder.d.ts +10 -0
  24. package/dist/cli/port-holder.js +26 -2
  25. package/dist/cli.js +139 -23
  26. package/dist/crawl/crawl-tools.js +12 -2
  27. package/dist/crawl/crawl.d.ts +13 -0
  28. package/dist/crawl/crawl.js +50 -2
  29. package/dist/daemon/daemon-resilience.js +2 -2
  30. package/dist/daemon/loopback-alias.d.ts +32 -0
  31. package/dist/daemon/loopback-alias.js +80 -0
  32. package/dist/events/contradictions.d.ts +14 -0
  33. package/dist/events/contradictions.js +26 -17
  34. package/dist/events/predicate-eval.d.ts +26 -3
  35. package/dist/events/predicate-eval.js +284 -95
  36. package/dist/events/predicate-parse.d.ts +1 -1
  37. package/dist/events/predicate-parse.js +18 -4
  38. package/dist/events/predicate.js +170 -18
  39. package/dist/events/ring-buffer.d.ts +8 -0
  40. package/dist/events/ring-buffer.js +34 -1
  41. package/dist/flows/flow-classify.js +1 -1
  42. package/dist/flows/flow-tools.js +12 -4
  43. package/dist/flows/heal-run.js +1 -1
  44. package/dist/honesty/blind-spots.js +6 -2
  45. package/dist/honesty/honesty.d.ts +11 -0
  46. package/dist/honesty/honesty.js +9 -1
  47. package/dist/honesty/unsettled.d.ts +40 -0
  48. package/dist/honesty/unsettled.js +78 -0
  49. package/dist/honesty/verified.d.ts +7 -0
  50. package/dist/honesty/verified.js +27 -4
  51. package/dist/http-server.js +11 -1
  52. package/dist/index.js +29 -2
  53. package/dist/init/agent-rules.d.ts +16 -0
  54. package/dist/init/agent-rules.js +96 -17
  55. package/dist/init/astro-patch.d.ts +2 -1
  56. package/dist/init/astro-patch.js +11 -5
  57. package/dist/init/capabilities.d.ts +22 -0
  58. package/dist/init/capabilities.js +76 -0
  59. package/dist/init/confirm.d.ts +96 -0
  60. package/dist/init/confirm.js +138 -0
  61. package/dist/init/cra.js +3 -2
  62. package/dist/init/detect.d.ts +8 -0
  63. package/dist/init/detect.js +15 -0
  64. package/dist/init/non-js-project.d.ts +27 -0
  65. package/dist/init/non-js-project.js +84 -0
  66. package/dist/init/plan-framework.d.ts +9 -0
  67. package/dist/init/plan-framework.js +72 -16
  68. package/dist/init/plan.d.ts +40 -4
  69. package/dist/init/plan.js +183 -43
  70. package/dist/init/run.d.ts +24 -2
  71. package/dist/init/run.js +181 -46
  72. package/dist/init/slash-command.d.ts +1 -1
  73. package/dist/init/slash-command.js +46 -6
  74. package/dist/init/snippets.d.ts +64 -5
  75. package/dist/init/snippets.js +220 -31
  76. package/dist/init/vite-config.js +29 -3
  77. package/dist/input/network-mock-tools.d.ts +19 -0
  78. package/dist/input/network-mock-tools.js +29 -4
  79. package/dist/license/license.js +1 -1
  80. package/dist/mcp/mcp-proxy.d.ts +69 -3
  81. package/dist/mcp/mcp-proxy.js +215 -23
  82. package/dist/mcp/mcp.js +58 -7
  83. package/dist/mcp/proxy-lifecycle.d.ts +20 -1
  84. package/dist/mcp/proxy-lifecycle.js +22 -1
  85. package/dist/pool/browser-pool.d.ts +22 -2
  86. package/dist/pool/browser-pool.js +44 -4
  87. package/dist/pool/playwright-launcher.js +10 -2
  88. package/dist/project/learned-routes.d.ts +24 -0
  89. package/dist/project/learned-routes.js +63 -0
  90. package/dist/project/project-store.d.ts +6 -0
  91. package/dist/project/project-store.js +25 -1
  92. package/dist/session/live-control-tools.js +39 -2
  93. package/dist/session/live-control.d.ts +7 -9
  94. package/dist/session/live-control.js +34 -1
  95. package/dist/session/no-session-diagnosis.d.ts +23 -4
  96. package/dist/session/no-session-diagnosis.js +128 -44
  97. package/dist/session/no-session-watch.js +12 -0
  98. package/dist/session/ref-provenance.d.ts +54 -0
  99. package/dist/session/ref-provenance.js +61 -0
  100. package/dist/session/review-store.d.ts +19 -0
  101. package/dist/session/review-store.js +34 -5
  102. package/dist/session/session-lease.js +1 -1
  103. package/dist/session/session-manager.d.ts +9 -0
  104. package/dist/session/session-manager.js +11 -0
  105. package/dist/session/session.d.ts +8 -0
  106. package/dist/session/session.js +12 -0
  107. package/dist/status-payload.d.ts +13 -1
  108. package/dist/status-payload.js +6 -1
  109. package/dist/telemetry/app-instrumented.d.ts +37 -0
  110. package/dist/telemetry/app-instrumented.js +54 -0
  111. package/dist/telemetry/automation-hint.d.ts +17 -0
  112. package/dist/telemetry/automation-hint.js +71 -0
  113. package/dist/telemetry/bug-found.d.ts +0 -20
  114. package/dist/telemetry/bug-found.js +38 -21
  115. package/dist/telemetry/cli-telemetry.js +7 -1
  116. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  117. package/dist/telemetry/daemon-telemetry.js +8 -4
  118. package/dist/telemetry/feedback.js +1 -1
  119. package/dist/telemetry/init-telemetry.js +9 -1
  120. package/dist/telemetry/install-source.d.ts +36 -0
  121. package/dist/telemetry/install-source.js +40 -0
  122. package/dist/telemetry/run-telemetry.js +4 -1
  123. package/dist/telemetry/session-metrics.d.ts +3 -3
  124. package/dist/telemetry/session-metrics.js +9 -4
  125. package/dist/telemetry/telemetry.d.ts +7 -1
  126. package/dist/telemetry/telemetry.js +17 -2
  127. package/dist/telemetry/tool-refused.d.ts +19 -0
  128. package/dist/telemetry/tool-refused.js +55 -0
  129. package/dist/telemetry/verification-of.js +72 -8
  130. package/dist/temp-dir.d.ts +27 -0
  131. package/dist/temp-dir.js +46 -0
  132. package/dist/tools/act-tools.js +77 -11
  133. package/dist/tools/assert-grade.d.ts +1 -1
  134. package/dist/tools/assert-grade.js +27 -5
  135. package/dist/tools/assert-verdict.js +25 -1
  136. package/dist/tools/contract-tools.js +4 -0
  137. package/dist/tools/dynamic-tools.js +1 -1
  138. package/dist/tools/error-recovery.d.ts +10 -0
  139. package/dist/tools/error-recovery.js +70 -5
  140. package/dist/tools/feedback-invite.js +1 -1
  141. package/dist/tools/invoke-tool.js +149 -32
  142. package/dist/tools/lease-tools.d.ts +29 -1
  143. package/dist/tools/lease-tools.js +58 -5
  144. package/dist/tools/navigate-result.js +1 -1
  145. package/dist/tools/observe-tools.js +1 -1
  146. package/dist/tools/observed-nothing.js +1 -1
  147. package/dist/tools/react-grace.d.ts +48 -0
  148. package/dist/tools/react-grace.js +87 -0
  149. package/dist/tools/read-tools.js +20 -4
  150. package/dist/tools/settle-in-flight.d.ts +79 -0
  151. package/dist/tools/settle-in-flight.js +106 -0
  152. package/dist/tools/snapshot-delta.d.ts +7 -0
  153. package/dist/tools/snapshot-delta.js +130 -6
  154. package/dist/tools/tool-kit.d.ts +52 -5
  155. package/dist/tools/tool-kit.js +55 -24
  156. package/dist/tools/tool-surface.js +28 -9
  157. package/dist/tools/tools.js +38 -4
  158. package/dist/visual/capture-cleanup.d.ts +16 -0
  159. package/dist/visual/capture-cleanup.js +39 -0
  160. package/dist/visual/visual-tools.d.ts +18 -0
  161. package/dist/visual/visual-tools.js +24 -5
  162. package/docs/README.md +56 -11
  163. package/docs/actions.mdx +87 -0
  164. package/docs/agent-cheatsheet.md +102 -26
  165. package/docs/architecture.md +46 -35
  166. package/docs/benchmarks.md +76 -72
  167. package/docs/best-practices.mdx +211 -0
  168. package/docs/capabilities.mdx +387 -0
  169. package/docs/cli/affected.mdx +51 -0
  170. package/docs/cli/capsules.mdx +69 -0
  171. package/docs/cli/cloud.mdx +127 -0
  172. package/docs/cli/doctor.mdx +76 -0
  173. package/docs/cli/drive.mdx +55 -0
  174. package/docs/cli/feedback.mdx +67 -0
  175. package/docs/cli/gate.mdx +64 -0
  176. package/docs/cli/help.mdx +92 -0
  177. package/docs/cli/hunt.mdx +57 -0
  178. package/docs/cli/identify.mdx +62 -0
  179. package/docs/cli/init.mdx +94 -0
  180. package/docs/cli/kill.mdx +91 -0
  181. package/docs/cli/license.mdx +48 -0
  182. package/docs/cli/mcp.mdx +61 -0
  183. package/docs/cli/open.mdx +68 -0
  184. package/docs/cli/restart.mdx +60 -0
  185. package/docs/cli/rollback.mdx +35 -0
  186. package/docs/cli/serve.mdx +62 -0
  187. package/docs/cli/status.mdx +64 -0
  188. package/docs/cli/stop.mdx +56 -0
  189. package/docs/cli/telemetry.mdx +57 -0
  190. package/docs/cli/update.mdx +52 -0
  191. package/docs/cli/verify.mdx +57 -0
  192. package/docs/cli/version.mdx +39 -0
  193. package/docs/cli/watch.mdx +55 -0
  194. package/docs/cli.mdx +219 -0
  195. package/docs/debugging.md +13 -9
  196. package/docs/deploy-checks.md +15 -11
  197. package/docs/desktop-apps.md +49 -40
  198. package/docs/desktop.mdx +188 -0
  199. package/docs/docs.json +363 -0
  200. package/docs/enterprise.md +20 -16
  201. package/docs/faq.mdx +88 -0
  202. package/docs/fixtures.md +11 -7
  203. package/docs/flows.md +26 -22
  204. package/docs/for-agents.md +71 -0
  205. package/docs/frameworks.mdx +199 -0
  206. package/docs/gate-plan.md +45 -41
  207. package/docs/gates.md +23 -19
  208. package/docs/getting-started.md +76 -65
  209. package/docs/human-control.md +22 -18
  210. package/docs/index.mdx +101 -0
  211. package/docs/install-agentic.mdx +215 -0
  212. package/docs/install-manual.mdx +194 -0
  213. package/docs/instrumentation.mdx +232 -0
  214. package/docs/integration-patterns.md +38 -34
  215. package/docs/local-registry.md +16 -12
  216. package/docs/multi-agent-testing.md +16 -12
  217. package/docs/packages/babel-plugin.mdx +70 -0
  218. package/docs/packages/browser.mdx +111 -0
  219. package/docs/packages/core.mdx +106 -0
  220. package/docs/packages/electron.mdx +87 -0
  221. package/docs/packages/eslint-plugin.mdx +84 -0
  222. package/docs/packages/next.mdx +91 -0
  223. package/docs/packages/react.mdx +91 -0
  224. package/docs/packages/server.mdx +98 -0
  225. package/docs/packages/tauri.mdx +63 -0
  226. package/docs/packages/test.mdx +108 -0
  227. package/docs/packages/vite-plugin.mdx +96 -0
  228. package/docs/packages.mdx +217 -0
  229. package/docs/platform-integration.md +33 -26
  230. package/docs/predicates.mdx +179 -0
  231. package/docs/quickstart.mdx +211 -0
  232. package/docs/recipes.mdx +242 -0
  233. package/docs/skill-file.mdx +105 -0
  234. package/docs/state-management.mdx +197 -0
  235. package/docs/style.css +14 -0
  236. package/docs/system-map.md +47 -32
  237. package/docs/telemetry-contract.md +101 -46
  238. package/docs/telemetry-events.mdx +430 -0
  239. package/docs/telemetry.md +63 -50
  240. package/docs/testing.md +10 -6
  241. package/docs/token-efficiency.md +30 -26
  242. package/docs/tools-act-and-wait.mdx +123 -0
  243. package/docs/tools-act-sequence.mdx +120 -0
  244. package/docs/tools-act.mdx +82 -0
  245. package/docs/tools-assert.mdx +70 -0
  246. package/docs/tools-console.mdx +59 -0
  247. package/docs/tools-inspect.mdx +85 -0
  248. package/docs/tools-navigate.mdx +63 -0
  249. package/docs/tools-network.mdx +86 -0
  250. package/docs/tools-observe.mdx +89 -0
  251. package/docs/tools-overview.mdx +157 -0
  252. package/docs/tools-query.mdx +82 -0
  253. package/docs/tools-session-and-feedback.mdx +91 -0
  254. package/docs/tools-sessions.mdx +65 -0
  255. package/docs/tools-snapshot.mdx +75 -0
  256. package/docs/tools-state.mdx +81 -0
  257. package/docs/tools-tools-and-run.mdx +125 -0
  258. package/docs/tools-wait-for.mdx +66 -0
  259. package/docs/troubleshooting.mdx +193 -0
  260. package/docs/usage.md +175 -153
  261. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  262. package/docs/vs-playwright-mcp.mdx +85 -0
  263. package/docs/vs-screenshots.mdx +96 -0
  264. package/docs/what-is-reticle.mdx +63 -0
  265. package/docs/why-reticle.mdx +107 -0
  266. package/package.json +2 -2
  267. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  268. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  269. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  270. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  271. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  272. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  273. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  274. package/docs/matrix/MATRIX.md +0 -26
  275. package/docs/matrix/README.md +0 -59
  276. package/docs/mint.json +0 -61
package/dist/cli.js CHANGED
@@ -22,10 +22,12 @@ import { installDaemonResilience, installProxyResilience, recordExitReason, Daem
22
22
  import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
23
23
  import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
24
24
  import { everServedToolCall } from './daemon/daemon-usefulness.js';
25
- import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, } from './cli/cli-launch.js';
25
+ import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, drivePortConflict, openInBrowser, openCommand, } from './cli/cli-launch.js';
26
26
  import { handleVerify } from './cli/cli-verify.js';
27
+ import { runKill } from './cli/cli-kill.js';
27
28
  import { summarizeHunt } from './hunt/hunt-report.js';
28
29
  import { runInit } from './init/run.js';
30
+ import { confirmInstall, nodeConfirmDeps } from './init/confirm.js';
29
31
  import { handleDoctor } from './cli/cli-doctor.js';
30
32
  import { buildNodeIo } from './init/node-io.js';
31
33
  import { describeLicense } from './license/license.js';
@@ -38,6 +40,7 @@ import { reportCliRun } from './telemetry/cli-telemetry.js';
38
40
  export { parseCliArgs, CLI_USAGE };
39
41
  function handleInit(parsed) {
40
42
  const cwd = process.cwd();
43
+ const io = buildNodeIo(cwd);
41
44
  const result = runInit({
42
45
  cwd,
43
46
  port: parsed.port,
@@ -45,9 +48,14 @@ function handleInit(parsed) {
45
48
  dryRun: parsed.dryRun,
46
49
  install: parsed.install,
47
50
  ...(parsed.app === undefined ? {} : { app: parsed.app }),
48
- }, buildNodeIo(cwd));
49
- if (!result.ok)
50
- process.exit(1);
51
+ // The outcome is reported by confirmInstall instead, once it knows whether an app connected —
52
+ // `init` writing files was never the same thing as `init` working (#269).
53
+ deferOutcome: true,
54
+ }, io);
55
+ void confirmInstall(result, io, nodeConfirmDeps(parsed.port ?? RETICLE_DEFAULT_PORT)).then(() => {
56
+ if (!result.ok)
57
+ process.exit(1);
58
+ });
51
59
  }
52
60
  function handleServe(parsed) {
53
61
  void serveWithHonestExit(parsed);
@@ -132,6 +140,10 @@ async function serveWithHonestExit(parsed) {
132
140
  /** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
133
141
  const SERVE_BIND_TIMEOUT_MS = 15_000;
134
142
  const PRESENCE_POLL_MS = 150;
143
+ /** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
144
+ const GRACEFUL_STOP_MS = 5000;
145
+ /** How long after SIGKILL before we accept the process is not ours to kill and say so. */
146
+ const FORCED_STOP_MS = 2000;
135
147
  async function waitForPresence(port, want, timeoutMs) {
136
148
  const deadline = Date.now() + timeoutMs;
137
149
  for (;;) {
@@ -153,22 +165,61 @@ function handleStop(port, quiet) {
153
165
  }
154
166
  process.kill(pid, 'SIGTERM');
155
167
  const started = Date.now();
168
+ let escalated = false;
156
169
  const poll = setInterval(() => {
157
170
  if (!isAlive(pid)) {
158
171
  clearInterval(poll);
159
172
  removePid(port);
160
173
  if (!quiet)
161
- log('reticle_daemon_stopped', { port, pid });
174
+ log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
175
+ return;
176
+ }
177
+ const waited = Date.now() - started;
178
+ // A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
179
+ // up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
180
+ // first two minutes of setup. Escalate instead: graceful first, then unconditional.
181
+ if (!escalated && waited > GRACEFUL_STOP_MS) {
182
+ escalated = true;
183
+ try {
184
+ process.kill(pid, 'SIGKILL');
185
+ }
186
+ catch {
187
+ // Already gone between the liveness check and here — the next tick reports it stopped.
188
+ }
162
189
  return;
163
190
  }
164
- if (Date.now() - started > 5000) {
191
+ if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
165
192
  clearInterval(poll);
193
+ // Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
194
+ // zombie. Neither is retryable, and both need the pid named so a human can act on it.
166
195
  if (!quiet)
167
- log('reticle_daemon_stop_timeout', { port, pid });
196
+ log('reticle_daemon_stop_timeout', { port, pid, escalated });
168
197
  process.exit(1);
169
198
  }
170
199
  }, 100);
171
200
  }
201
+ /**
202
+ * `reticle restart` — free the port, then put a daemon back on it and prove it bound.
203
+ *
204
+ * The two halves are reported separately on purpose. A restart that killed the old daemon and then
205
+ * could not start a new one leaves the user in a different place from one that could not kill
206
+ * anything at all, and a single `ok: false` cannot tell them which happened.
207
+ */
208
+ async function handleRestart(port, force) {
209
+ const freed = await runKill(port, force);
210
+ if (!freed) {
211
+ log('reticle_restart_aborted', {
212
+ port,
213
+ reason: 'the port was not freed, so nothing was started — the old holder is still there',
214
+ });
215
+ process.exit(1);
216
+ return;
217
+ }
218
+ // Reuses `serve`'s path, which waits for the daemon to ANSWER before claiming it started and exits
219
+ // non-zero when it does not. A restart that reports success for a daemon that never bound is the
220
+ // same lie `serve` already stopped telling.
221
+ await serveWithHonestExit({ port, headless: true, http: false });
222
+ }
172
223
  function handleStatus(port) {
173
224
  const pid = readPid(port);
174
225
  if (null === pid || !isAlive(pid)) {
@@ -343,8 +394,12 @@ function handleOpen(requestedPort, url) {
343
394
  log('reticle_open', {
344
395
  port,
345
396
  error: `could not launch a browser: ${launchError}`,
346
- recovery: 'Open the url yourself, or run `reticle doctor` — it checks the pieces this command ' +
347
- 'depends on.',
397
+ // Points at the OS handler, not at `reticle doctor`. Doctor's browser check is about
398
+ // Reticle's own Chromium, which this command never touches — the same misdirect the
399
+ // no-session note below spells out at length, left standing here for one more release.
400
+ recovery: 'Nothing was opened. This command asks the OS to open a url in your default browser ' +
401
+ `(\`${openCommand(decision.url, process.platform).cmd}\` on this platform) and that ` +
402
+ 'failed, so the fix is on the OS side: open the url yourself, or set a default browser.',
348
403
  });
349
404
  process.exit(1);
350
405
  return;
@@ -360,11 +415,21 @@ function handleOpen(requestedPort, url) {
360
415
  ...(connected
361
416
  ? {}
362
417
  : {
363
- note: 'the browser was launched but no Reticle session appeared. The app may still be ' +
364
- 'loading, or it is not wired to this bridge — run `reticle doctor`, and check the ' +
365
- 'app connects on port ' +
366
- String(port) +
367
- '.',
418
+ // Two claims used to live here that this command cannot support. It said the browser
419
+ // "was launched" — it asked the OS to open a URL and cannot see whether a window
420
+ // appeared — and it offered "the app may still be loading" as a cause with equal
421
+ // weight to the real one. It then pointed at `reticle doctor`, whose Chromium check is
422
+ // about a browser THIS command never uses, so a missing Chromium reads as the
423
+ // explanation for a session that is missing for an unrelated reason. Both misdirects
424
+ // were reported from the field, each costing several calls of app-side wiring hunt.
425
+ note: `the URL was handed to the system default browser (this command does not use ` +
426
+ `Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
427
+ `to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
428
+ `carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
429
+ `\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
430
+ `wired, give the page a moment and check the browser console: the SDK announces its ` +
431
+ `own connect failures there, including the one it refuses to make from a ` +
432
+ `non-localhost host without allowNonLocalhost.`,
368
433
  }),
369
434
  });
370
435
  })
@@ -420,7 +485,7 @@ function handleDaemonInner(parsed) {
420
485
  // The reason rides out on the session summary, which is the only event that fires at this
421
486
  // exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
422
487
  // it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
423
- // 299 of 321 "outages". See SessionSummary.exit.
488
+ // the large majority of "outages". See SessionSummary.exit.
424
489
  .shutdown(reason)
425
490
  .then(() => server.close())
426
491
  .then(() => {
@@ -529,8 +594,13 @@ function handleMcp(opts) {
529
594
  daemonArgs.push(HTTP_TOKEN_FLAG, httpToken);
530
595
  }
531
596
  spawnDaemon(process.execPath, scriptPath, daemonArgs, port);
532
- log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
597
+ // Announce the daemon only once the PORT ACCEPTS. This line used to be written the instant the
598
+ // child was spawned, which on a Windows first bootstrap meant `reticle_mcp_daemon_started`
599
+ // followed about ten seconds later by `reticle_mcp_daemon_unavailable` and a first
600
+ // `reticle_sessions` that expired. A readiness signal that precedes readiness is worse than
601
+ // none: a client that believes it stops waiting for the thing that has not happened.
533
602
  await waitForDaemon(port);
603
+ log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
534
604
  };
535
605
  // Start the proxy WHATEVER happened to the daemon.
536
606
  //
@@ -571,20 +641,57 @@ function handleMcp(opts) {
571
641
  });
572
642
  }
573
643
  function handleLegacyDrive(parsed) {
644
+ void driveWithHonestConflict(parsed);
645
+ }
646
+ /**
647
+ * Refuse the port BEFORE binding it, and name what is holding it.
648
+ *
649
+ * `drive` went straight to `start`, which binds. The listen error surfaces asynchronously on the
650
+ * server object long after `start` has resolved, so the `.catch` on that promise could never see it
651
+ * and the process died with the raw `node:net` EADDRINUSE stack — in the one situation this command
652
+ * is most often run in, since `reticle_sessions` recommends `reticle drive` for a throttled tab and
653
+ * a throttled tab nearly always coexists with the daemon holding the port.
654
+ */
655
+ async function driveWithHonestConflict(parsed) {
656
+ const presence = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
657
+ const conflict = drivePortConflict(presence, parsed.port, { ourPid: readPid(parsed.port) });
658
+ if (conflict !== undefined) {
659
+ log('reticle_drive_port_conflict', { port: parsed.port, presence, reason: conflict });
660
+ process.stderr.write(`${conflict}\n`);
661
+ process.exit(1);
662
+ return;
663
+ }
664
+ // The probe cannot close the race: something can take the port between here and the bind, and the
665
+ // bind reports it on the server, outside every promise this function holds. Catching it at the
666
+ // process is the only place that sees it at all — and only EADDRINUSE, so a genuine crash still
667
+ // crashes rather than being dressed up as a port conflict. Who won the race is not knowable from
668
+ // in here, so the sentence used is the one that is true of any holder.
669
+ process.on('uncaughtException', (error) => {
670
+ const code = error?.code;
671
+ if ('EADDRINUSE' !== code)
672
+ throw error;
673
+ const reason = drivePortConflict(PortPresence.FOREIGN, parsed.port);
674
+ log('reticle_drive_port_conflict', {
675
+ port: parsed.port,
676
+ presence: PortPresence.FOREIGN,
677
+ reason,
678
+ });
679
+ process.stderr.write(`${String(reason)}\n`);
680
+ process.exit(1);
681
+ });
574
682
  const options = {
575
683
  port: parsed.port,
576
684
  driveUrl: parsed.driveUrl,
577
685
  headless: parsed.headless,
578
686
  };
579
- start(options)
580
- .then(() => {
687
+ try {
688
+ await start(options);
581
689
  log('reticle_started', { port: parsed.port });
582
- })
583
- .catch((error) => {
584
- const message = error instanceof Error ? error.message : String(error);
585
- log('reticle_start_failed', { error: message });
690
+ }
691
+ catch (error) {
692
+ log('reticle_start_failed', { error: error instanceof Error ? error.message : String(error) });
586
693
  process.exit(1);
587
- });
694
+ }
588
695
  }
589
696
  function main() {
590
697
  // Before anything reads process.env — notably the telemetry gate and the bridge's security
@@ -638,6 +745,15 @@ function main() {
638
745
  case 'stop':
639
746
  handleStop(parsed.port, parsed.quiet);
640
747
  break;
748
+ case 'kill':
749
+ void runKill(parsed.port, parsed.force).then((freed) => {
750
+ if (!freed)
751
+ process.exit(1);
752
+ });
753
+ break;
754
+ case 'restart':
755
+ void handleRestart(parsed.port, parsed.force);
756
+ break;
641
757
  case 'status':
642
758
  handleStatus(parsed.port);
643
759
  break;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { ReticleTool } from '../tools/tool-names.js';
3
3
  import { asNumber, asString } from '../tools/tools-helpers.js';
4
4
  import { crawl } from './crawl.js';
5
+ import { routeFromUrl, routesFromEvents } from '../project/learned-routes.js';
5
6
  const nodeSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
7
  /**
7
8
  * The autonomous "smart monkey" tool. Builds on reticle_explore (which only LISTS) by
@@ -58,8 +59,10 @@ export const CRAWL_TOOLS = [
58
59
  .describe('Present only when the page exceeded one snapshot, so controls past the cap were never listed. When present, a zero-anomaly result does NOT mean the app is clean.'),
59
60
  truncated: z.boolean(),
60
61
  },
61
- handler: (deps, args) => {
62
+ handler: async (deps, args) => {
62
63
  const session = deps.sessions.resolve(asString(args['sessionId']));
64
+ const since = session.elapsed();
65
+ const initialRoute = routeFromUrl(session.url);
63
66
  const maxSteps = asNumber(args['maxSteps']);
64
67
  const settleMs = asNumber(args['settleMs']);
65
68
  const scope = asString(args['scope']);
@@ -69,7 +72,14 @@ export const CRAWL_TOOLS = [
69
72
  ...(scope !== undefined ? { scope } : {}),
70
73
  ...(true === args['confirmDangerous'] ? { confirmDangerous: true } : {}),
71
74
  };
72
- return crawl(session, opts, nodeSleep);
75
+ const report = await crawl(session, opts, nodeSleep);
76
+ const routes = [
77
+ ...(initialRoute === undefined ? [] : [initialRoute]),
78
+ ...routesFromEvents(session.eventsSince(since)),
79
+ ];
80
+ if (routes.length > 0)
81
+ await deps.project.recordRoutes(routes);
82
+ return report;
73
83
  },
74
84
  },
75
85
  ];
@@ -39,6 +39,19 @@ export interface CrawlAnomaly {
39
39
  }
40
40
  /** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
41
41
  export declare const CAPPED_SNAPSHOT_NOTE = "the page exceeded one snapshot, so controls past the cap were never seen \u2014 interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest";
42
+ /**
43
+ * Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
44
+ *
45
+ * The enumeration happens once, before the first click, so a control that only exists after a login,
46
+ * a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
47
+ * screen visited three controls, its click on "Sign in" produced seven more, and it returned
48
+ * `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
49
+ * that is a clean bill of health for an app the crawl never entered.
50
+ *
51
+ * The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
52
+ * floor) reached by a different route, and a second vocabulary for it would just be more to learn.
53
+ */
54
+ export declare function revealedControlsNote(count: number): string;
42
55
  export interface CrawlReport {
43
56
  interactiveFound: number;
44
57
  stepsRun: number;
@@ -6,6 +6,23 @@ import { ReticleTool } from '../tools/tool-names.js';
6
6
  import { findContradictions } from '../events/contradictions.js';
7
7
  /** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
8
8
  export const CAPPED_SNAPSHOT_NOTE = 'the page exceeded one snapshot, so controls past the cap were never seen — interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest';
9
+ /**
10
+ * Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
11
+ *
12
+ * The enumeration happens once, before the first click, so a control that only exists after a login,
13
+ * a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
14
+ * screen visited three controls, its click on "Sign in" produced seven more, and it returned
15
+ * `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
16
+ * that is a clean bill of health for an app the crawl never entered.
17
+ *
18
+ * The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
19
+ * floor) reached by a different route, and a second vocabulary for it would just be more to learn.
20
+ */
21
+ export function revealedControlsNote(count) {
22
+ return (`the page changed as you clicked: ${String(count)} control(s) that did not exist when this crawl ` +
23
+ 'started were never visited — interactiveFound is a floor, not a total; re-run the crawl from ' +
24
+ 'the state you are now in to cover them');
25
+ }
9
26
  /** Any buffer event that proves the app reacted to a click (vs a dead/no-op control). */
10
27
  function isActivity(e) {
11
28
  return (e.type === EventType.NET_REQUEST ||
@@ -50,6 +67,25 @@ export function legitimatelyInert(desc) {
50
67
  return true;
51
68
  return /\b(textbox|searchbox|combobox|spinbutton)\b/.test(desc);
52
69
  }
70
+ /**
71
+ * How many interactive controls exist now that did not exist when the crawl enumerated.
72
+ *
73
+ * Keyed on ref, which is the element's identity: a control that re-rendered keeps its ref and is not
74
+ * new, and one that vanished simply does not appear. Returns 0 rather than throwing if the snapshot
75
+ * fails — this is a disclosure, and failing the whole crawl to report on coverage would be worse
76
+ * than the silence it is fixing.
77
+ */
78
+ async function countRevealed(session, opts, before) {
79
+ const snap = await session.command(ReticleCommand.SNAPSHOT, {
80
+ mode: 'interactive',
81
+ ...(opts.scope !== undefined ? { scope: opts.scope } : {}),
82
+ });
83
+ if (!snap.ok)
84
+ return 0;
85
+ const tree = (snap.result ?? {}).tree ?? '';
86
+ const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
87
+ return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
88
+ }
53
89
  export async function crawl(session, opts, sleep) {
54
90
  const maxSteps = opts.maxSteps ?? CRAWL_DEFAULTS.MAX_STEPS;
55
91
  const settleMs = opts.settleMs ?? CRAWL_DEFAULTS.SETTLE_MS;
@@ -168,6 +204,14 @@ export async function crawl(session, opts, sleep) {
168
204
  });
169
205
  }
170
206
  }
207
+ // One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
208
+ // the crawl itself unlocked — a dashboard behind a login, a modal's contents, a tab's panel — was
209
+ // never in `items` and never had a chance to be visited. Counted by ref against what was
210
+ // enumerated, so a control that merely moved or re-rendered under the same ref is not miscounted
211
+ // as new, and a surface that SHRANK (a modal closed) reports nothing, because nothing went
212
+ // unvisited. Skipped when the snapshot was already capped: that case has its own note and a second
213
+ // one would only muddy it.
214
+ const revealed = coverageCapped ? 0 : await countRevealed(session, opts, items);
171
215
  return {
172
216
  interactiveFound: items.length,
173
217
  stepsRun,
@@ -177,8 +221,12 @@ export async function crawl(session, opts, sleep) {
177
221
  // True when coverage was bounded for EITHER reason: the step budget ran out, or the page was
178
222
  // bigger than one snapshot. Conflating "I stopped early" with "I saw everything" is what turns a
179
223
  // partial sweep into "all controls healthy".
180
- truncated: items.length > stepsRun || coverageCapped,
181
- ...(coverageCapped ? { coverageNote: CAPPED_SNAPSHOT_NOTE } : {}),
224
+ truncated: items.length > stepsRun || coverageCapped || revealed > 0,
225
+ ...(coverageCapped
226
+ ? { coverageNote: CAPPED_SNAPSHOT_NOTE }
227
+ : revealed > 0
228
+ ? { coverageNote: revealedControlsNote(revealed) }
229
+ : {}),
182
230
  // A bare `stepsRun: 0` made "the page had no controls" and "it had 34 and none were clicked" the
183
231
  // same answer. See crawl-empty.
184
232
  ...(() => {
@@ -42,8 +42,8 @@ const DISCONNECT_CODES = new Set([
42
42
  /**
43
43
  * Nobody went away — nobody was ever there. A refused connect is the daemon not being up YET.
44
44
  *
45
- * This is the difference that made `runtime_crashed` useless. Measured 2026-08-10/11: **84 of 84
46
- * crash events were one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
45
+ * This is the difference that made `runtime_crashed` useless. In the field **every crash event
46
+ * carried one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
47
47
  * never-verified set.** The proxy is BUILT to tolerate this — it answers the handshake from cache
48
48
  * and wakes a daemon on the next request — so reporting it as a crash meant the one metric that is
49
49
  * supposed to say "Reticle broke" said nothing but "the daemon had not booted yet", and a real crash
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
3
+ *
4
+ * The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
5
+ * `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
6
+ * default bridge URL says `localhost`. So on the platform that is most of our installs, and the
7
+ * browser that is most of that, the documented default cannot connect — and the error text names the
8
+ * daemon, containers and WSL, none of which is the cause.
9
+ *
10
+ * This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
11
+ * WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
12
+ * second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
13
+ * only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
14
+ *
15
+ * Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
16
+ * leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
17
+ * precondition for serving.
18
+ */
19
+ /** IPv6 loopback. The other half of what `localhost` can mean. */
20
+ export declare const IPV6_LOOPBACK = "::1";
21
+ export interface LoopbackAlias {
22
+ /** False when the alias could not be bound — the daemon still serves IPv4 as before. */
23
+ opened: boolean;
24
+ close: () => Promise<void>;
25
+ }
26
+ /**
27
+ * Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
28
+ *
29
+ * Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
30
+ * trust holds — the alias does not widen who may connect, only which address they may name.
31
+ */
32
+ export declare function openLoopbackAlias(port: number): Promise<LoopbackAlias>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
3
+ *
4
+ * The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
5
+ * `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
6
+ * default bridge URL says `localhost`. So on the platform that is most of our installs, and the
7
+ * browser that is most of that, the documented default cannot connect — and the error text names the
8
+ * daemon, containers and WSL, none of which is the cause.
9
+ *
10
+ * This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
11
+ * WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
12
+ * second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
13
+ * only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
14
+ *
15
+ * Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
16
+ * leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
17
+ * precondition for serving.
18
+ */
19
+ import net from 'node:net';
20
+ import { LOOPBACK_HOST } from '@reticlehq/core';
21
+ /** IPv6 loopback. The other half of what `localhost` can mean. */
22
+ export const IPV6_LOOPBACK = '::1';
23
+ /**
24
+ * Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
25
+ *
26
+ * Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
27
+ * trust holds — the alias does not widen who may connect, only which address they may name.
28
+ */
29
+ export function openLoopbackAlias(port) {
30
+ /** Every socket this alias has open, so shutdown can end them instead of waiting them out. */
31
+ const live = new Set();
32
+ const server = net.createServer((inbound) => {
33
+ const outbound = net.connect(port, LOOPBACK_HOST);
34
+ live.add(inbound).add(outbound);
35
+ const drop = (socket) => {
36
+ live.delete(socket);
37
+ };
38
+ inbound.on('close', () => drop(inbound));
39
+ outbound.on('close', () => drop(outbound));
40
+ // A dead peer on either side must tear down the pair, not surface as an unhandled 'error'
41
+ // event — which in Node terminates the daemon this alias exists to make reachable.
42
+ inbound.on('error', () => outbound.destroy());
43
+ outbound.on('error', () => inbound.destroy());
44
+ inbound.pipe(outbound);
45
+ outbound.pipe(inbound);
46
+ });
47
+ return new Promise((resolve) => {
48
+ /**
49
+ * Stop listening and END EVERYTHING, rather than draining.
50
+ *
51
+ * `server.close()` fires its callback only once every existing connection has gone, so a single
52
+ * held-open socket — a keep-alive, an SSE stream, exactly what this daemon serves — means the
53
+ * callback never fires. The daemon's shutdown chain awaits this, so an un-drained alias silently
54
+ * became a daemon that ignores SIGTERM: `reticle stop` timed out, the process stayed alive, and
55
+ * the port stayed held. Caught by the heartbeat spec, which is the one gate that watches a
56
+ * daemon actually die.
57
+ *
58
+ * A best-effort side listener must never be able to hold up the shutdown of the thing it exists
59
+ * to make reachable. We are exiting; ending these sockets is the correct outcome, not a
60
+ * compromise.
61
+ */
62
+ const close = () => new Promise((done) => {
63
+ server.close(() => done());
64
+ for (const socket of live)
65
+ socket.destroy();
66
+ live.clear();
67
+ });
68
+ server.once('error', () => {
69
+ // Nothing was bound, so there is nothing to close — but the caller still gets a close it can
70
+ // call unconditionally, rather than a shape it has to branch on.
71
+ resolve({ opened: false, close: () => Promise.resolve() });
72
+ });
73
+ server.listen(port, IPV6_LOOPBACK, () => {
74
+ // Never the reason the process stays up: if the daemon is otherwise done, this must not be
75
+ // what keeps the event loop alive.
76
+ server.unref();
77
+ resolve({ opened: true, close });
78
+ });
79
+ });
80
+ }
@@ -35,6 +35,20 @@ export interface ContradictionOptions {
35
35
  * contain. See `findUnitMismatches` for the measured case this exists for.
36
36
  */
37
37
  prior?: readonly ReticleEvent[] | undefined;
38
+ /**
39
+ * Event time at which the action that opened this window was dispatched — the attribution floor.
40
+ *
41
+ * `duplicate-request` claims "one user action was performed", and that claim is only sound over ONE
42
+ * action's window. `reticle_observe` takes a caller-supplied window that can be arbitrarily wide, so
43
+ * two legitimate separate saves to the same endpoint read as a double submit — and the finding then
44
+ * sat on every verdict for that tab. Undefined means nothing attributed the window to an action, and
45
+ * the rule stays silent rather than accusing an app of something nobody can show it did.
46
+ *
47
+ * ponytail: a NET_REQUEST is stamped when it COMPLETED, so a write dispatched before the action and
48
+ * landing after it counts as inside. Keying on the matching NET_PENDING would fix that; it has not
49
+ * been worth the second index.
50
+ */
51
+ actionSince?: number | undefined;
38
52
  /**
39
53
  * DOM mutations observed INSIDE the target's own subtree, as the act tool measured them.
40
54
  *
@@ -281,23 +281,32 @@ export function findContradictions(allEvents, options = {}) {
281
281
  });
282
282
  }
283
283
  }
284
- // ── The same write fired more than once ─────────────────────────────────────────────────────
285
- const writeCounts = new Map();
286
- for (const call of settled) {
287
- if (!isMutating(call))
288
- continue;
289
- const key = `${call.method} ${call.url}`;
290
- writeCounts.set(key, (writeCounts.get(key) ?? 0) + 1);
291
- }
292
- for (const [key, count] of writeCounts) {
293
- if (count < 2)
294
- continue;
295
- found.push({
296
- kind: ContradictionKind.DUPLICATE_REQUEST,
297
- claim: 'one user action was performed',
298
- counter: `the same write fired ${String(count)} times`,
299
- detail: `${key} ×${String(count)}`,
300
- });
284
+ // ── The same write fired more than once, inside ONE action's window ─────────────────────────
285
+ // Attribution is the whole rule here, not a refinement of it: counting `method + url` over whatever
286
+ // window the caller handed in turns two legitimate separate saves into a double submit. See
287
+ // ContradictionOptions.actionSince.
288
+ const actionSince = options.actionSince;
289
+ if (actionSince !== undefined) {
290
+ const writeCounts = new Map();
291
+ for (const event of events) {
292
+ if (event.type !== EventType.NET_REQUEST || event.t < actionSince)
293
+ continue;
294
+ const call = netCall(event);
295
+ if (!isMutating(call))
296
+ continue;
297
+ const key = `${call.method} ${call.url}`;
298
+ writeCounts.set(key, (writeCounts.get(key) ?? 0) + 1);
299
+ }
300
+ for (const [key, count] of writeCounts) {
301
+ if (count < 2)
302
+ continue;
303
+ found.push({
304
+ kind: ContradictionKind.DUPLICATE_REQUEST,
305
+ claim: 'one user action was performed',
306
+ counter: `the same write fired ${String(count)} times`,
307
+ detail: `${key} ×${String(count)}`,
308
+ });
309
+ }
301
310
  }
302
311
  // ── The UI advanced over a request that never came back ─────────────────────────────────────
303
312
  // Gated on the UI having moved: an in-flight request while the app is still visibly waiting is
@@ -1,4 +1,4 @@
1
- import { ElementState, PredicateKind, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
1
+ import { ElementState, PredicateKind, type ElementDescriptor, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
2
2
  import { z } from 'zod';
3
3
  export type Predicate = {
4
4
  kind: typeof PredicateKind.ELEMENT;
@@ -58,11 +58,34 @@ export type Predicate = {
58
58
  kind: typeof PredicateKind.NOT;
59
59
  predicate: Predicate;
60
60
  };
61
+ export interface ResidualQueryChecks {
62
+ /** Dropped fields this side CAN check, as [field, wanted value] pairs. */
63
+ checks: [string, string][];
64
+ /** Dropped fields with no descriptor to check them against — refuse rather than ignore. */
65
+ unusable: string[];
66
+ }
61
67
  /**
62
- * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
63
- * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
68
+ * Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
69
+ *
70
+ * The alternative — refuse every dropped field — breaks calls that work today and that our own
71
+ * cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
72
+ * Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
73
+ * outcome the caller was already assuming.
64
74
  */
75
+ export declare function residualQueryChecks(query: ElementQuery): ResidualQueryChecks;
76
+ /** Does this element satisfy every field the locator dropped? */
77
+ export declare function satisfiesResiduals(element: ElementDescriptor, checks: readonly [string, string][]): boolean;
78
+ /** How the element's own reading of a dropped field should be REPORTED back on a failure. */
79
+ export declare function describeResidual(element: ElementDescriptor, field: string): string;
65
80
  export declare const PredicateSchema: z.ZodType<Predicate>;
81
+ /**
82
+ * The fields a given predicate kind accepts, read off the schema itself.
83
+ *
84
+ * Derived rather than listed so the two can never disagree: a rejection message that names a stale
85
+ * field set is worse than one that names none, because the agent trusts it and retries into the same
86
+ * wall. Empty for a kind that is not in the union.
87
+ */
88
+ export declare function predicateFieldsFor(kind: string): readonly string[];
66
89
  export interface EvalResult {
67
90
  pass: boolean;
68
91
  /**