@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
@@ -8,8 +8,9 @@
8
8
  * on stable, human-authored substrings of the thrown messages (see session-manager.ts, the flow/
9
9
  * baseline stores). No clock, no IO — unit-testable in isolation.
10
10
  */
11
- import { TRANSPORT_LIMITS } from '@reticlehq/core';
11
+ import { RefusalReason, TRANSPORT_LIMITS } from '@reticlehq/core';
12
12
  import { SELF_RECOVERING_MARKER } from '../session/no-session-diagnosis.js';
13
+ import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
13
14
  /** Marks where an over-long message had its middle removed. Named so tests assert it by value. */
14
15
  const ELISION = '… [elided] …';
15
16
  /**
@@ -41,17 +42,17 @@ function capMessage(message) {
41
42
  * the agent precisely what to do.
42
43
  */
43
44
  const SELF_RECOVERING = [
44
- /cannot drive native input/i,
45
+ { match: /cannot drive native input/i, reason: RefusalReason.UNSUPPORTED },
45
46
  // The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
46
47
  // the current ids" to a message that just named them contradicts the shorter path it offers, and
47
48
  // a contradicted instruction is how one agent came to retry a dead id twelve times.
48
- /Connected right now:/i,
49
+ { match: /Connected right now:/i, reason: RefusalReason.NO_SESSION },
49
50
  ];
50
51
  /** A message that already carries its own concrete next action, so nothing should be appended. */
51
52
  function isSelfRecovering(message) {
52
53
  if (message.includes(SELF_RECOVERING_MARKER))
53
54
  return true;
54
- return SELF_RECOVERING.some((pattern) => pattern.test(message));
55
+ return SELF_RECOVERING.some((rule) => rule.match.test(message));
55
56
  }
56
57
  /** The recovery hints, named so they are not free strings and can be asserted in tests. */
57
58
  export const RECOVERY = {
@@ -129,9 +130,46 @@ export const RECOVERY = {
129
130
  'where it actually is. This is app/flow drift, which is exactly what the flow exists to catch.',
130
131
  NO_POOL: 'Reticle cannot launch its own browser here: the daemon-managed pool is absent, or Playwright ' +
131
132
  'has no Chromium installed (the message above says which). Run Reticle via `reticle mcp`, and ' +
132
- 'ask the human for `npx playwright install chromium` if that is what is missing. Meanwhile drive ' +
133
+ // Pinned to the bundled playwright: the unpinned command installs a revision this daemon never
134
+ // looks at, so the human runs it, it succeeds, and nothing changes.
135
+ 'ask the human for `' +
136
+ chromiumInstallCommand(bundledPlaywrightVersion()) +
137
+ '` if that is what is missing. Meanwhile drive ' +
133
138
  'a tab the human already has open — reticle_sessions lists them.',
134
139
  };
140
+ /**
141
+ * Which OWNER each recovery belongs to, for `tool_refused`.
142
+ *
143
+ * A `Record` over the recovery keys rather than a second table of patterns, and that is the point:
144
+ * adding a recovery without classifying it does not compile. A parallel regex list would have been
145
+ * correct on the day it was written and silently wrong at the next addition — the drift that has
146
+ * already cost this repo a published number twice, and telemetry drift is not recoverable after the
147
+ * fact.
148
+ */
149
+ const REASON_OF = {
150
+ NO_SESSION: RefusalReason.NO_SESSION,
151
+ MULTIPLE_SESSIONS: RefusalReason.NO_SESSION,
152
+ UNKNOWN_SESSION: RefusalReason.NO_SESSION,
153
+ SCOPE_MISMATCH: RefusalReason.NO_SESSION,
154
+ SESSION_GONE: RefusalReason.NO_SESSION,
155
+ THROTTLED: RefusalReason.NOT_READY,
156
+ COMMAND_TIMEOUT: RefusalReason.NOT_READY,
157
+ NO_POOL: RefusalReason.NOT_READY,
158
+ TOKEN_REQUIRED: RefusalReason.NOT_READY,
159
+ MISSING_BASELINE: RefusalReason.NO_MATCH,
160
+ MISSING_RECORDING: RefusalReason.NO_MATCH,
161
+ STALE_REF: RefusalReason.NO_MATCH,
162
+ NO_SUCH_OPTION: RefusalReason.NO_MATCH,
163
+ FLOW_STEP_MISSING: RefusalReason.NO_MATCH,
164
+ UNSUPPORTED_SURFACE: RefusalReason.UNSUPPORTED,
165
+ NOT_EDITABLE: RefusalReason.UNSUPPORTED,
166
+ CONFIRM_DANGEROUS: RefusalReason.UNSUPPORTED,
167
+ WRONG_TARGET: RefusalReason.UNSUPPORTED,
168
+ BAD_ARGUMENTS: RefusalReason.BAD_ARGS,
169
+ INVALID_NAME: RefusalReason.BAD_ARGS,
170
+ };
171
+ /** Hint text back to its reason. The hints are distinct strings, so this inverts cleanly. */
172
+ const REASON_BY_HINT = new Map(Object.entries(RECOVERY).map(([key, hint]) => [hint, REASON_OF[key]]));
135
173
  /** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */
136
174
  const RULES = [
137
175
  { match: /no browser session connected/i, hint: RECOVERY.NO_SESSION },
@@ -211,6 +249,33 @@ export function recoveryFor(message) {
211
249
  }
212
250
  return undefined;
213
251
  }
252
+ /**
253
+ * WHY a tool refused, as one of the six buckets in core's `RefusalReason`.
254
+ *
255
+ * Reads the SAME rules `buildErrorPayload` reads, in the same order, so the reason we report and the
256
+ * advice the agent got can never describe different things. Anything unrecognised is `other`, which
257
+ * is the bucket that says the classifier has a blind spot — and a growing `other` is a finding about
258
+ * this table rather than about anybody's app.
259
+ */
260
+ export function refusalReasonFor(rawMessage) {
261
+ const message = zodArrayAsSentence(rawMessage);
262
+ // The no-session diagnosis inspected the machine and named the cause itself, so it never reaches
263
+ // the recovery table. It is the single largest refusal there is; missing it would gut the metric.
264
+ if (message.includes(SELF_RECOVERING_MARKER))
265
+ return RefusalReason.NO_SESSION;
266
+ for (const rule of SELF_RECOVERING) {
267
+ if (rule.match.test(message))
268
+ return rule.reason;
269
+ }
270
+ // Same order as buildErrorPayload: a schema rejection is the agent's own call, whatever else in
271
+ // the message might also match.
272
+ if (ARGUMENT_REJECTION.test(message))
273
+ return RefusalReason.BAD_ARGS;
274
+ if (INVALID_NAME_REJECTION.test(message))
275
+ return RefusalReason.BAD_ARGS;
276
+ const hint = recoveryFor(message);
277
+ return (hint === undefined ? undefined : REASON_BY_HINT.get(hint)) ?? RefusalReason.OTHER;
278
+ }
214
279
  /**
215
280
  * A schema rejection: the call named a parameter that does not exist, or gave one the wrong type.
216
281
  *
@@ -38,7 +38,7 @@ export const FrictionKind = {
38
38
  const REPEAT_INVITE_AT = 3;
39
39
  const LINES = {
40
40
  [FrictionKind.UNKNOWN_TOOL]: 'no such tool — reticle_feedback tells us what you expected it to do, which is how it gets built',
41
- [FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback — an ' +
41
+ [FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback. An ' +
42
42
  'unknown verdict is our defect, not yours',
43
43
  [FrictionKind.REPEATING]: 'stuck on the same call? reticle_feedback — say what you were trying to do and it gets fixed',
44
44
  [FrictionKind.REFUSED]: 'if that diagnosis was wrong or unhelpful, reticle_feedback — error messages are a product ' +
@@ -6,11 +6,16 @@ import { takeUpdateNudge } from '../update/update-nudge.js';
6
6
  import { takeVersionSkew } from '../version/version-nudge.js';
7
7
  import { noteToolCall } from '../daemon/daemon-usefulness.js';
8
8
  import { bugsInResult } from '../telemetry/bug-found.js';
9
+ import { noteToolServed, reportToolRefused } from '../telemetry/tool-refused.js';
10
+ import { buildErrorPayload, refusalReasonFor } from './error-recovery.js';
11
+ import { resultIsError } from '../mcp/mcp-is-error.js';
9
12
  import { verificationOf } from '../telemetry/verification-of.js';
10
13
  import { asString } from './tools-helpers.js';
14
+ import { EnvelopeKey } from './tool-kit.js';
11
15
  import { ReticleTool } from './tool-names.js';
12
16
  import { takeFeedbackPrompt } from './feedback-tools.js';
13
17
  import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
18
+ import { noteRefsMinted, wrongTabRefusal } from '../session/ref-provenance.js';
14
19
  import { span } from '../trace.js';
15
20
  import { frictionOf, inviteFor } from './feedback-invite.js';
16
21
  /**
@@ -29,6 +34,20 @@ const ACTION_TOOLS = new Set([
29
34
  ReticleTool.ACT_SEQUENCE,
30
35
  ReticleTool.ACT_AND_WAIT,
31
36
  ]);
37
+ /**
38
+ * Tools whose result hands the agent refs. The tab they were minted in is remembered, so the call
39
+ * that spends one cannot be routed to a different tab without saying so — see ref-provenance.ts.
40
+ *
41
+ * Over-inclusion is the safe direction here and under-inclusion is not dangerous either: both ends
42
+ * produce a REFUSAL naming the two tabs, never a silent drive against the wrong one.
43
+ */
44
+ const REF_MINTING_TOOLS = new Set([
45
+ ReticleTool.SNAPSHOT,
46
+ ReticleTool.QUERY,
47
+ ReticleTool.INSPECT,
48
+ ReticleTool.EXPLORE,
49
+ ReticleTool.CRAWL,
50
+ ]);
32
51
  export const SESSION_BOUND_TOOLS = new Set([
33
52
  ReticleTool.SNAPSHOT,
34
53
  ReticleTool.QUERY,
@@ -133,6 +152,54 @@ function reportBugsFound(toolName, result) {
133
152
  });
134
153
  }
135
154
  }
155
+ /**
156
+ * Report a refused call, at the one place both dispatch paths cross.
157
+ *
158
+ * A refusal arrives two ways and they are the same event: the handler THROWS (the common case — no
159
+ * session, a stale ref, a schema rejection), or it RETURNS `{ error }`, which is this codebase's
160
+ * refusal convention and just as much a refusal. Both are classified by the same table the agent's
161
+ * recovery advice comes from, so the reason we record and the advice it got cannot describe
162
+ * different things.
163
+ *
164
+ * `reticle_run` is skipped for the same reason `bugsInResult` skips it: it is a WRAPPER whose
165
+ * handler calls `runTool` on the real tool, which already reported that refusal under the real
166
+ * tool's name, and then hands the same failure back out to be reported a second time as
167
+ * `reticle_run`. A count that doubles because of HOW a tool was reached is not a measurement.
168
+ */
169
+ function reportRefusal(toolName, message) {
170
+ if (toolName === ReticleTool.RUN)
171
+ return;
172
+ reportToolRefused(toolName, refusalReasonFor(message));
173
+ }
174
+ /**
175
+ * Which friction this result represents, counting the invitation if there is one.
176
+ *
177
+ * Feedback is the highest-yield channel this product has, and `feedbackPrompted` is the denominator
178
+ * that says whether inviting mid-task works or is decoration. It read as near-empty because the
179
+ * count sat behind the session-bound early return, so a session-EXEMPT tool never invited and a tool
180
+ * that THREW never got there — and a throw is the commonest refusal there is.
181
+ *
182
+ * `reticle_run` is skipped for the same reason it is skipped for refusals and defects: the wrapper's
183
+ * handler calls `runTool` on the real tool, which already invited under the real tool's name, and
184
+ * the failure is then handed back out to be counted a second time. A doubled denominator makes the
185
+ * feedback rate read half.
186
+ */
187
+ function frictionInviteFor(toolName, raw) {
188
+ if (!isPlainObject(raw) || toolName === ReticleTool.RUN)
189
+ return undefined;
190
+ const friction = frictionOf({
191
+ unknownTool: false,
192
+ verifiedUnknown: 'unknown' === raw['verified'],
193
+ repeatRun: getSessionMetrics().currentRun,
194
+ errored: resultIsError(raw),
195
+ // A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
196
+ // loop this release exists to encourage — get told they are stuck.
197
+ producedVerdict: 'verified' in raw,
198
+ });
199
+ if (friction !== undefined)
200
+ getSessionMetrics().recordFeedbackPrompt();
201
+ return friction;
202
+ }
136
203
  /**
137
204
  * The single entry point both the MCP server and the programmatic invoker call instead of
138
205
  * `tool.handler` directly. Runs the handler, then — for a live-session tool returning a plain
@@ -188,11 +255,13 @@ export async function runTool(tool, deps, args) {
188
255
  // prevents: the agent learns its argument was too big, which is something it can fix.
189
256
  const oversized = firstOversizedArg(args);
190
257
  if (oversized !== undefined) {
191
- return {
192
- error: `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
193
- 'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
194
- 'value — a selector or an id rather than a document, or a slice of the text you need.',
195
- };
258
+ const error = `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
259
+ 'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
260
+ 'value — a selector or an id rather than a document, or a slice of the text you need.';
261
+ // Refused before the handler was ever entered, and still a refusal the agent has to recover
262
+ // from. Reported here rather than at the return below, which this branch never reaches.
263
+ reportRefusal(tool.name, error);
264
+ return { error };
196
265
  }
197
266
  // An ACTION is what gets abandoned. Counted here, against verifications, so "the agent drove the
198
267
  // page and then wandered off" becomes a number instead of an impression.
@@ -217,6 +286,25 @@ export async function runTool(tool, deps, args) {
217
286
  session = undefined;
218
287
  }
219
288
  }
289
+ // A ref that was handed out by a DIFFERENT tab, on a call that named no session. Refused here, at
290
+ // the one point that knows both the ref and the tab resolution picked — the handler only learns
291
+ // that the ref missed, and answers that with "the DOM re-rendered", which is a confident diagnosis
292
+ // of the wrong thing. The other half is worse: the same ref can resolve to a different element in
293
+ // the guessed tab and return a green about a page nobody asked about.
294
+ if (session !== undefined) {
295
+ const wrongTab = wrongTabRefusal({
296
+ ref: asString(args['ref']),
297
+ explicitSessionId: rawSessionId,
298
+ chosenSessionId: session.id,
299
+ connected: () => deps.sessions.list(),
300
+ });
301
+ if (wrongTab !== undefined) {
302
+ // Refused before the handler was entered, so it is reported here — the same shape as the
303
+ // oversized-argument refusal above.
304
+ reportRefusal(tool.name, wrongTab);
305
+ return { error: wrongTab };
306
+ }
307
+ }
220
308
  const leaseId = session?.id ?? rawSessionId;
221
309
  if (leaseId !== undefined)
222
310
  deps.pool?.touch(leaseId);
@@ -227,6 +315,22 @@ export async function runTool(tool, deps, args) {
227
315
  // nests under it. Free when off — see trace.ts.
228
316
  raw = await span('tool.handler', { tool: tool.name, session: session?.id }, () => tool.handler(deps, args));
229
317
  }
318
+ catch (error) {
319
+ // The commonest refusal shape by far, and the one nothing could see: the message is built, handed
320
+ // to the agent by the MCP boundary, and discarded. Reported here rather than at that boundary
321
+ // because there are two of them (mcp.ts and the reticle_run hatch) and this is the one place both
322
+ // go through — a third would otherwise be invisible from the day it was added.
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ reportRefusal(tool.name, message);
325
+ // The invitation on THIS path is the one `buildErrorPayload` attaches when it does not recognise
326
+ // the error, and it was never counted — so the denominator excluded the commonest friction there
327
+ // is. Gated on the payload actually carrying the ask, because a recognised error gets a recovery
328
+ // instead and counting an invitation nobody was shown breaks the ratio in the other direction.
329
+ if (tool.name !== ReticleTool.RUN && buildErrorPayload(message).feedback !== undefined) {
330
+ getSessionMetrics().recordFeedbackPrompt();
331
+ }
332
+ throw error;
333
+ }
230
334
  finally {
231
335
  // In a `finally` so a THROWN call still settles: otherwise every failing tool would leak a
232
336
  // concurrency slot and peakConcurrentTools would climb forever on an unhealthy session.
@@ -243,6 +347,20 @@ export async function runTool(tool, deps, args) {
243
347
  recordVerification(tool.name, raw, Date.now() - startedAt, session?.brand);
244
348
  reportBugsFound(tool.name, raw);
245
349
  }
350
+ // The other half of the refusal surface. A top-level `error` string IS this codebase's refusal
351
+ // convention, so half the tools refuse by RETURNING one rather than throwing — and reading only the
352
+ // throw path would have measured half the wall and called it the whole of it, the same way `isError`
353
+ // once did. A call that was served clears the retry chain, so the next refusal after it is a first
354
+ // refusal rather than a retry of something unrelated.
355
+ if (resultIsError(raw))
356
+ reportRefusal(tool.name, raw.error);
357
+ else {
358
+ noteToolServed();
359
+ // Which tab these refs belong to. Recorded on the way OUT, so the next call that spends one is
360
+ // measured against the tab that actually produced it.
361
+ if (session !== undefined && REF_MINTING_TOOLS.has(tool.name))
362
+ noteRefsMinted(session.id);
363
+ }
246
364
  const prompt = isPlainObject(raw) ? takeFeedbackPrompt(tool.name) : undefined;
247
365
  // Same one-shot channel as the feedback ask: the agent is mid-task, so anything it would have to go
248
366
  // and ASK for is something it will never ask for. Spliced on ANY tool result, not just a
@@ -258,16 +376,32 @@ export async function runTool(tool, deps, args) {
258
376
  // report announced as accepted and then silently lost is the failure the awaited send existed to
259
377
  // prevent. See feedback-delivery.ts.
260
378
  const undelivered = isPlainObject(raw) ? takeFeedbackUndelivered() : undefined;
261
- const result = prompt === undefined && update === undefined && skew === undefined && undelivered === undefined
379
+ // Invite feedback at the moment of friction, worded for what just happened. Counted, because
380
+ // `feedback_submitted / feedbackPrompted` is the only thing that says whether the invitation works
381
+ // at all rather than being decoration.
382
+ //
383
+ // This used to sit below the session-bound early return, which put it behind two conditions that
384
+ // exclude most friction there is. A session-EXEMPT tool never reached it, and neither did a tool
385
+ // that THREW — and throwing is the commonest refusal shape by a wide margin, i.e. exactly the
386
+ // `refused` friction the line is written for. `errored` could only ever see the handlers that
387
+ // RETURN an error. So the denominator was counting a small, unrepresentative slice of the
388
+ // invitations and reading near-empty, which looks identical to a nudge that never fires.
389
+ const friction = frictionInviteFor(tool.name, raw);
390
+ const result = prompt === undefined &&
391
+ update === undefined &&
392
+ skew === undefined &&
393
+ undelivered === undefined &&
394
+ friction === undefined
262
395
  ? raw
263
396
  : {
264
397
  ...raw,
265
- ...(prompt !== undefined ? { feedback_prompt: prompt } : {}),
266
- ...(update !== undefined ? { update_available: update } : {}),
267
- ...(skew !== undefined ? { version_skew: skew } : {}),
398
+ ...(friction !== undefined ? { [EnvelopeKey.FEEDBACK_INVITE]: inviteFor(friction) } : {}),
399
+ ...(prompt !== undefined ? { [EnvelopeKey.FEEDBACK_PROMPT]: prompt } : {}),
400
+ ...(update !== undefined ? { [EnvelopeKey.UPDATE_AVAILABLE]: update } : {}),
401
+ ...(skew !== undefined ? { [EnvelopeKey.VERSION_SKEW]: skew } : {}),
268
402
  ...(undelivered !== undefined
269
403
  ? {
270
- feedback_undelivered: `your earlier report did NOT send: ${undelivered}. Tell the human what you found so it is not lost.`,
404
+ [EnvelopeKey.FEEDBACK_UNDELIVERED]: `your earlier report did NOT send: ${undelivered}. Tell the human what you found so it is not lost.`,
271
405
  }
272
406
  : {}),
273
407
  };
@@ -286,33 +420,16 @@ export async function runTool(tool, deps, args) {
286
420
  // one-time pool-lease reminder or the age cleanup nudge. Splice them regardless.
287
421
  const lease = resolved.takeSessionLease();
288
422
  if (lease !== undefined)
289
- envelope['session_lease'] = lease;
423
+ envelope[EnvelopeKey.SESSION_LEASE] = lease;
290
424
  const warning = resolved.ageWarning();
291
425
  if (warning !== undefined)
292
- envelope['session_age_warning'] = warning;
293
- // Ask for a verdict when the agent has driven the page and not asked for one. 137 of the 140
294
- // verdict-less sessions measured over 2026-08-10/11 never called a verdict-producing tool ONCE;
426
+ envelope[EnvelopeKey.SESSION_AGE_WARNING] = warning;
427
+ // Ask for a verdict when the agent has driven the page and not asked for one. Almost every
428
+ // verdict-less session in the field never called a verdict-producing tool ONCE;
295
429
  // the counter behind this already existed and was reported only to us. One-shot per abandoned
296
430
  // run, same discipline as the pool lease — a hint on every call is noise that gets tuned out.
297
431
  const unverified = getSessionMetrics().takeUnverifiedNudge();
298
432
  if (unverified !== undefined)
299
- envelope['verify_next'] = unverified;
300
- // Invite feedback at the moment of friction, worded for what just happened. Feedback produced
301
- // ~11 of this release's fixes from 14 reports; the handshake instruction is what got those 14,
302
- // and this closes the gap it leaves — the agent mid-problem, not thinking about feedback tooling.
303
- // Counted so `feedback_submitted / feedbackPrompted` can tell us whether it works at all.
304
- const friction = frictionOf({
305
- unknownTool: false,
306
- verifiedUnknown: 'unknown' === result.verified,
307
- repeatRun: getSessionMetrics().currentRun,
308
- errored: 'error' in result,
309
- // A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
310
- // loop this release exists to encourage — get told they are stuck.
311
- producedVerdict: 'verified' in result,
312
- });
313
- if (friction !== undefined) {
314
- envelope['feedback_invite'] = inviteFor(friction);
315
- getSessionMetrics().recordFeedbackPrompt();
316
- }
433
+ envelope[EnvelopeKey.VERIFY_NEXT] = unverified;
317
434
  return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
318
435
  }
@@ -20,6 +20,26 @@ export declare function appendReticleParams(url: string, session: string, projec
20
20
  * of an internals-leaking wall of text.
21
21
  */
22
22
  export declare function cleanNavError(err: unknown): string;
23
+ /**
24
+ * The session a lease's tab actually registered as, or undefined while nothing has.
25
+ *
26
+ * Normally that is the lease id: `appendReticleParams` stamps `__reticle_session` on the URL and the
27
+ * SDK adopts it. An app that passes an explicit `session` to `connect()` keeps its own name instead,
28
+ * which is legitimate — a single-app fixture does it deliberately so a battery can address it by a
29
+ * known id. Matching on the lease id alone could never see those tabs, so the lease reported
30
+ * `ready: false` and a hint blaming a port mismatch while the session was connected and driveable.
31
+ *
32
+ * The URL marker is the evidence, and it is what makes this safe: two concurrent leases on one origin
33
+ * carry different markers, so neither can adopt the other's tab. Parsed rather than substring-matched,
34
+ * so `lease-abc` cannot claim `lease-abcdef`.
35
+ */
36
+ export declare function resolveLeasedSessionId(sessions: {
37
+ get: (id: string) => unknown;
38
+ all: () => {
39
+ id: string;
40
+ url?: string;
41
+ }[];
42
+ }, leaseId: string): string | undefined;
23
43
  /**
24
44
  * Poll until the leased tab's SDK has actually registered on the bridge, so the sessionId we return
25
45
  * is immediately usable (the page navigates first, then connects a moment later — without this wait
@@ -38,7 +58,15 @@ export declare function acquireLeasedSession(pool: {
38
58
  sessionId: string;
39
59
  release: () => Promise<void>;
40
60
  }>;
41
- }, hasSession: (id: string) => boolean, url: string, projectId?: string): Promise<{
61
+ /** Optional so a test double need not implement it; the real pool always does. */
62
+ alias?: (registeredId: string, leaseId: string) => void;
63
+ }, sessions: {
64
+ get: (id: string) => unknown;
65
+ all: () => {
66
+ id: string;
67
+ url?: string;
68
+ }[];
69
+ }, url: string, projectId?: string): Promise<{
42
70
  sessionId: string;
43
71
  release: () => Promise<void>;
44
72
  }>;
@@ -64,6 +64,34 @@ function newLeaseId() {
64
64
  : `${String(Date.now())}-${String(performance.now())}`;
65
65
  return `lease-${uuid}`;
66
66
  }
67
+ /**
68
+ * The session a lease's tab actually registered as, or undefined while nothing has.
69
+ *
70
+ * Normally that is the lease id: `appendReticleParams` stamps `__reticle_session` on the URL and the
71
+ * SDK adopts it. An app that passes an explicit `session` to `connect()` keeps its own name instead,
72
+ * which is legitimate — a single-app fixture does it deliberately so a battery can address it by a
73
+ * known id. Matching on the lease id alone could never see those tabs, so the lease reported
74
+ * `ready: false` and a hint blaming a port mismatch while the session was connected and driveable.
75
+ *
76
+ * The URL marker is the evidence, and it is what makes this safe: two concurrent leases on one origin
77
+ * carry different markers, so neither can adopt the other's tab. Parsed rather than substring-matched,
78
+ * so `lease-abc` cannot claim `lease-abcdef`.
79
+ */
80
+ export function resolveLeasedSessionId(sessions, leaseId) {
81
+ if (sessions.get(leaseId) !== undefined)
82
+ return leaseId;
83
+ return sessions.all().find((s) => sessionParamOf(s.url) === leaseId)?.id;
84
+ }
85
+ function sessionParamOf(url) {
86
+ if (url === undefined)
87
+ return undefined;
88
+ try {
89
+ return new URL(url).searchParams.get(RETICLE_URL_PARAM.SESSION) ?? undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
67
95
  const LEASE_READY_ATTEMPTS = 100;
68
96
  const LEASE_READY_POLL_MS = 100;
69
97
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -78,11 +106,20 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
78
106
  * Returns the session id plus its release; the caller must release in a finally so a crashed flow never
79
107
  * leaks a pool slot and starves the rest of the suite.
80
108
  */
81
- export async function acquireLeasedSession(pool, hasSession, url, projectId) {
109
+ export async function acquireLeasedSession(pool, sessions, url, projectId) {
82
110
  const sessionId = newLeaseId();
83
111
  const lease = await pool.acquire(appendReticleParams(url, sessionId, projectId), { sessionId });
84
- await waitForLeasedSession(() => hasSession(lease.sessionId));
85
- return { sessionId: lease.sessionId, release: () => lease.release() };
112
+ // Resolved, not assumed — see resolveLeasedSessionId. The parallel suite replays against whatever
113
+ // id comes back, so handing it the lease id for an app that named its own session sent every flow
114
+ // in the run at a session that does not exist.
115
+ let registeredId;
116
+ await waitForLeasedSession(() => {
117
+ registeredId = resolveLeasedSessionId(sessions, lease.sessionId);
118
+ return registeredId !== undefined;
119
+ });
120
+ if (registeredId !== undefined)
121
+ pool.alias?.(registeredId, lease.sessionId);
122
+ return { sessionId: registeredId ?? lease.sessionId, release: () => lease.release() };
86
123
  }
87
124
  export async function waitForLeasedSession(isConnected, sleeper = sleep, attempts = LEASE_READY_ATTEMPTS, pollMs = LEASE_READY_POLL_MS) {
88
125
  for (let i = 0; i < attempts; i++) {
@@ -111,6 +148,9 @@ export const LEASE_TOOLS = [
111
148
  ready: z
112
149
  .boolean()
113
150
  .describe('Whether the leased tab connected — false ⇒ the app may not embed @reticlehq/core.'),
151
+ expiresInMs: z
152
+ .number()
153
+ .describe('Milliseconds until this lease expires if untouched. Each tool call that targets this session resets the clock. Plan your work to finish or re-acquire before this runs out.'),
114
154
  leased: z.number().describe('How many contexts are currently leased from the pool.'),
115
155
  queued: z.number().describe('How many acquires are waiting for a free slot.'),
116
156
  hint: z.string().optional(),
@@ -135,11 +175,24 @@ export const LEASE_TOOLS = [
135
175
  throw new Error(`could not open ${url} — is the app running there? (${cleanNavError(err)})`);
136
176
  }
137
177
  // Wait for the leased tab's SDK to connect so the returned sessionId is usable right away.
138
- const ready = await waitForLeasedSession(() => deps.sessions.get(lease.sessionId) !== undefined);
178
+ // Resolved rather than assumed: an app that names its own session registers under that name,
179
+ // and the id we hand back has to be the one the agent can actually drive.
180
+ let registeredId;
181
+ const ready = await waitForLeasedSession(() => {
182
+ registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
183
+ return registeredId !== undefined;
184
+ });
185
+ // Tell the pool the other name this lease answers to. Every later touch and release arrives
186
+ // under the id we are about to hand back, and the pool is keyed by the id it navigated with;
187
+ // without this the touches miss, the lease ages out despite continuous activity, and the
188
+ // reaper closes the context mid-flow. See BrowserPool.alias and #157.
189
+ if (registeredId !== undefined)
190
+ pool.alias(registeredId, lease.sessionId);
139
191
  return {
140
- sessionId: lease.sessionId,
192
+ sessionId: registeredId ?? lease.sessionId,
141
193
  url,
142
194
  ready,
195
+ expiresInMs: pool.leaseTtlMs(),
143
196
  leased: pool.activeCount(),
144
197
  queued: pool.queuedCount(),
145
198
  ...(ready
@@ -30,7 +30,7 @@ arrival = null) {
30
30
  return {
31
31
  ...base,
32
32
  confirmed: false,
33
- note: 'ok means the navigation was DISPATCHED, not that the page arrived — the SDK is torn down by ' +
33
+ note: 'ok means the navigation was DISPATCHED, not that the page arrived. The SDK is torn down by ' +
34
34
  'the navigation itself, so nothing here can see the new document. Call reticle_sessions to ' +
35
35
  'confirm a session reconnected at the new URL before acting; if none appears, the page did ' +
36
36
  'not load or is not instrumented.',
@@ -179,7 +179,7 @@ export const OBSERVE_TOOLS = [
179
179
  // never asked about.
180
180
  const actCursor = session.lastAct.cursor();
181
181
  const judgingTheAct = actCursor !== undefined && actCursor >= since;
182
- const contradictions = findContradictions(filtered, judgingTheAct ? session.lastAct.effect() : {});
182
+ const contradictions = findContradictions(filtered, judgingTheAct ? { ...session.lastAct.effect(), actionSince: actCursor } : {});
183
183
  // carry session health — a throttled tab means the observed timeline may be incomplete.
184
184
  return withControl(session, {
185
185
  ...report,
@@ -28,6 +28,6 @@ export function noteEmptyRead(result, key, context) {
28
28
  ...result,
29
29
  // The distinction the field sweep could not make: this is a RESULT, not a failure to look.
30
30
  observed: true,
31
- note: `no ${context.noun}${window} — the observation ran and found none, which is a result, not a missing reading`,
31
+ note: `no ${context.noun}${window}; the observation ran and found none, which is a result, not a missing reading`,
32
32
  };
33
33
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * "The app ignored this response" is only sayable once the app has had a chance to read it.
3
+ *
4
+ * `response-ignored` fires when a write succeeded on the server and nothing on the client moved. It
5
+ * is a real and valuable finding — a lost write, a response parsed into the void, a render that never
6
+ * happened. It is also an ACCUSATION, and it is decided by where a window happens to end.
7
+ *
8
+ * Waiting for in-flight requests before taking the verdict made that boundary dangerous in a way it
9
+ * was not before. Previously the write was still pending when the window closed, so it never reached
10
+ * the settled list and this rule could not fire at all. Now the response lands inside the window BY
11
+ * DESIGN — and the app's re-render happens in a later task, a few milliseconds after it. Close the
12
+ * window in that gap and every channel agrees that the app took a successful write and did nothing.
13
+ *
14
+ * The verdict is then `verified:"no"` on a completely correct application, produced entirely by where
15
+ * we chose to stop looking. For a verification tool that is the more damaging direction of error: a
16
+ * false green is a missed catch, but a false accusation sends someone to fix code that is not broken,
17
+ * and it is how the instrument stops being believed.
18
+ *
19
+ * So the response is not the end of the window — the app's REACTION to it is. The grace is paid only
20
+ * in the exact case that would otherwise be accused: a successful mutating write with nothing moved
21
+ * after it. Every other action returns without a single poll.
22
+ *
23
+ * Deliberately short. This covers a task-boundary re-render, not a slow app: if nothing has moved
24
+ * within the grace, "ignored" has become an honest description and the finding should stand.
25
+ */
26
+ import type { SettleSource } from './settle-in-flight.js';
27
+ /**
28
+ * Is this a window that would be wrongly accused if it ended right now?
29
+ *
30
+ * True only for the accusable shape: a SUCCESSFUL MUTATING request settled, and nothing has moved. A
31
+ * read that changed nothing is a prefetch, a failed write is a different finding entirely, and an
32
+ * action that already moved the UI has nothing to wait for — none of them buy a grace period.
33
+ */
34
+ export declare function awaitsReaction(events: readonly {
35
+ type: string;
36
+ data: Record<string, unknown>;
37
+ }[]): boolean;
38
+ /**
39
+ * Wait, bounded by BOTH the grace and the caller's remaining budget, for the app to react to a write
40
+ * it has just been told succeeded.
41
+ *
42
+ * Returns whether the app moved. A `false` is not a verdict — it hands the question back to the
43
+ * contradiction engine, which will raise `response-ignored` exactly as it does today. The point is
44
+ * only that by then the app has been given its chance, so the finding means what it says.
45
+ */
46
+ export declare function waitForReaction(session: SettleSource, since: number, budgetMs: number, opts: {
47
+ sleep(ms: number): Promise<void>;
48
+ }): Promise<boolean>;