@reticlehq/server 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +64 -29
  3. package/dist/capsule/causal-summary.d.ts +20 -0
  4. package/dist/capsule/causal-summary.js +31 -6
  5. package/dist/capsule/divergence.js +7 -1
  6. package/dist/cli/chromium-hint.d.ts +33 -5
  7. package/dist/cli/chromium-hint.js +82 -12
  8. package/dist/cli/cli-doctor.js +28 -25
  9. package/dist/cli/cli-kill.d.ts +58 -0
  10. package/dist/cli/cli-kill.js +162 -0
  11. package/dist/cli/cli-launch.d.ts +17 -0
  12. package/dist/cli/cli-launch.js +28 -0
  13. package/dist/cli/cli-parse.d.ts +16 -1
  14. package/dist/cli/cli-parse.js +32 -9
  15. package/dist/cli/cli-port.d.ts +8 -0
  16. package/dist/cli/cli-port.js +16 -0
  17. package/dist/cli/cli-verify.js +2 -2
  18. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  19. package/dist/cli/doctor-sessions-line.js +66 -0
  20. package/dist/cli/port-holder.d.ts +10 -0
  21. package/dist/cli/port-holder.js +26 -2
  22. package/dist/cli.js +99 -14
  23. package/dist/crawl/crawl-tools.js +12 -2
  24. package/dist/crawl/crawl.d.ts +13 -0
  25. package/dist/crawl/crawl.js +50 -2
  26. package/dist/events/contradictions.d.ts +14 -0
  27. package/dist/events/contradictions.js +26 -17
  28. package/dist/events/predicate-eval.d.ts +20 -1
  29. package/dist/events/predicate-eval.js +125 -3
  30. package/dist/events/predicate.js +170 -18
  31. package/dist/flows/flow-classify.js +1 -1
  32. package/dist/flows/flow-tools.js +12 -4
  33. package/dist/flows/heal-run.js +1 -1
  34. package/dist/honesty/blind-spots.js +6 -2
  35. package/dist/honesty/unsettled.d.ts +40 -0
  36. package/dist/honesty/unsettled.js +78 -0
  37. package/dist/honesty/verified.d.ts +7 -0
  38. package/dist/honesty/verified.js +3 -3
  39. package/dist/index.js +3 -0
  40. package/dist/init/agent-rules.d.ts +16 -0
  41. package/dist/init/agent-rules.js +96 -22
  42. package/dist/init/astro-patch.d.ts +2 -1
  43. package/dist/init/astro-patch.js +11 -5
  44. package/dist/init/capabilities.d.ts +22 -0
  45. package/dist/init/capabilities.js +76 -0
  46. package/dist/init/confirm.d.ts +96 -0
  47. package/dist/init/confirm.js +138 -0
  48. package/dist/init/cra.js +3 -2
  49. package/dist/init/non-js-project.js +29 -5
  50. package/dist/init/plan-framework.js +53 -15
  51. package/dist/init/plan.d.ts +40 -4
  52. package/dist/init/plan.js +163 -40
  53. package/dist/init/run.d.ts +24 -2
  54. package/dist/init/run.js +148 -42
  55. package/dist/init/slash-command.d.ts +1 -1
  56. package/dist/init/slash-command.js +46 -6
  57. package/dist/init/snippets.d.ts +44 -4
  58. package/dist/init/snippets.js +161 -37
  59. package/dist/init/vite-config.js +29 -3
  60. package/dist/input/network-mock-tools.d.ts +19 -0
  61. package/dist/input/network-mock-tools.js +29 -4
  62. package/dist/license/license.js +1 -1
  63. package/dist/mcp/mcp-proxy.d.ts +69 -3
  64. package/dist/mcp/mcp-proxy.js +205 -20
  65. package/dist/mcp/mcp.js +23 -7
  66. package/dist/mcp/proxy-lifecycle.d.ts +1 -1
  67. package/dist/mcp/proxy-lifecycle.js +1 -1
  68. package/dist/pool/browser-pool.d.ts +22 -2
  69. package/dist/pool/browser-pool.js +44 -4
  70. package/dist/project/learned-routes.d.ts +24 -0
  71. package/dist/project/learned-routes.js +63 -0
  72. package/dist/project/project-store.d.ts +6 -0
  73. package/dist/project/project-store.js +25 -1
  74. package/dist/session/no-session-diagnosis.d.ts +19 -0
  75. package/dist/session/no-session-diagnosis.js +71 -18
  76. package/dist/session/no-session-watch.js +12 -0
  77. package/dist/session/ref-provenance.d.ts +54 -0
  78. package/dist/session/ref-provenance.js +61 -0
  79. package/dist/session/session-lease.js +1 -1
  80. package/dist/telemetry/automation-hint.d.ts +17 -0
  81. package/dist/telemetry/automation-hint.js +71 -0
  82. package/dist/telemetry/bug-found.d.ts +0 -20
  83. package/dist/telemetry/bug-found.js +38 -21
  84. package/dist/telemetry/cli-telemetry.js +7 -1
  85. package/dist/telemetry/feedback.js +1 -1
  86. package/dist/telemetry/init-telemetry.js +9 -1
  87. package/dist/telemetry/install-source.d.ts +36 -0
  88. package/dist/telemetry/install-source.js +40 -0
  89. package/dist/telemetry/run-telemetry.js +4 -1
  90. package/dist/telemetry/session-metrics.js +5 -0
  91. package/dist/telemetry/telemetry.d.ts +5 -1
  92. package/dist/telemetry/telemetry.js +15 -2
  93. package/dist/telemetry/tool-refused.d.ts +19 -0
  94. package/dist/telemetry/tool-refused.js +55 -0
  95. package/dist/telemetry/verification-of.js +46 -7
  96. package/dist/temp-dir.d.ts +27 -0
  97. package/dist/temp-dir.js +46 -0
  98. package/dist/tools/act-tools.js +45 -1
  99. package/dist/tools/assert-grade.d.ts +1 -1
  100. package/dist/tools/assert-grade.js +27 -5
  101. package/dist/tools/assert-verdict.js +18 -1
  102. package/dist/tools/contract-tools.js +4 -0
  103. package/dist/tools/dynamic-tools.js +1 -1
  104. package/dist/tools/error-recovery.d.ts +10 -0
  105. package/dist/tools/error-recovery.js +64 -4
  106. package/dist/tools/feedback-invite.js +1 -1
  107. package/dist/tools/invoke-tool.js +139 -23
  108. package/dist/tools/lease-tools.d.ts +29 -1
  109. package/dist/tools/lease-tools.js +58 -5
  110. package/dist/tools/navigate-result.js +1 -1
  111. package/dist/tools/observe-tools.js +1 -1
  112. package/dist/tools/observed-nothing.js +1 -1
  113. package/dist/tools/react-grace.d.ts +48 -0
  114. package/dist/tools/react-grace.js +87 -0
  115. package/dist/tools/read-tools.js +20 -4
  116. package/dist/tools/settle-in-flight.d.ts +79 -0
  117. package/dist/tools/settle-in-flight.js +106 -0
  118. package/dist/tools/snapshot-delta.d.ts +7 -0
  119. package/dist/tools/snapshot-delta.js +130 -6
  120. package/dist/tools/tool-surface.js +19 -0
  121. package/dist/tools/tools.js +16 -4
  122. package/docs/README.md +56 -11
  123. package/docs/actions.mdx +87 -0
  124. package/docs/agent-cheatsheet.md +93 -29
  125. package/docs/architecture.md +46 -35
  126. package/docs/benchmarks.md +76 -72
  127. package/docs/best-practices.mdx +211 -0
  128. package/docs/capabilities.mdx +387 -0
  129. package/docs/cli/affected.mdx +51 -0
  130. package/docs/cli/capsules.mdx +69 -0
  131. package/docs/cli/cloud.mdx +127 -0
  132. package/docs/cli/doctor.mdx +76 -0
  133. package/docs/cli/drive.mdx +55 -0
  134. package/docs/cli/feedback.mdx +67 -0
  135. package/docs/cli/gate.mdx +64 -0
  136. package/docs/cli/help.mdx +92 -0
  137. package/docs/cli/hunt.mdx +57 -0
  138. package/docs/cli/identify.mdx +62 -0
  139. package/docs/cli/init.mdx +94 -0
  140. package/docs/cli/kill.mdx +91 -0
  141. package/docs/cli/license.mdx +48 -0
  142. package/docs/cli/mcp.mdx +61 -0
  143. package/docs/cli/open.mdx +68 -0
  144. package/docs/cli/restart.mdx +60 -0
  145. package/docs/cli/rollback.mdx +35 -0
  146. package/docs/cli/serve.mdx +62 -0
  147. package/docs/cli/status.mdx +64 -0
  148. package/docs/cli/stop.mdx +56 -0
  149. package/docs/cli/telemetry.mdx +57 -0
  150. package/docs/cli/update.mdx +52 -0
  151. package/docs/cli/verify.mdx +57 -0
  152. package/docs/cli/version.mdx +39 -0
  153. package/docs/cli/watch.mdx +55 -0
  154. package/docs/cli.mdx +219 -0
  155. package/docs/debugging.md +13 -9
  156. package/docs/deploy-checks.md +15 -11
  157. package/docs/desktop-apps.md +49 -40
  158. package/docs/desktop.mdx +188 -0
  159. package/docs/docs.json +363 -0
  160. package/docs/enterprise.md +20 -16
  161. package/docs/faq.mdx +88 -0
  162. package/docs/fixtures.md +11 -7
  163. package/docs/flows.md +26 -22
  164. package/docs/for-agents.md +71 -0
  165. package/docs/frameworks.mdx +199 -0
  166. package/docs/gate-plan.md +45 -41
  167. package/docs/gates.md +22 -18
  168. package/docs/getting-started.md +76 -65
  169. package/docs/human-control.md +22 -18
  170. package/docs/index.mdx +101 -0
  171. package/docs/install-agentic.mdx +215 -0
  172. package/docs/install-manual.mdx +194 -0
  173. package/docs/instrumentation.mdx +232 -0
  174. package/docs/integration-patterns.md +38 -34
  175. package/docs/local-registry.md +16 -12
  176. package/docs/multi-agent-testing.md +16 -12
  177. package/docs/packages/babel-plugin.mdx +70 -0
  178. package/docs/packages/browser.mdx +111 -0
  179. package/docs/packages/core.mdx +106 -0
  180. package/docs/packages/electron.mdx +87 -0
  181. package/docs/packages/eslint-plugin.mdx +84 -0
  182. package/docs/packages/next.mdx +91 -0
  183. package/docs/packages/react.mdx +91 -0
  184. package/docs/packages/server.mdx +98 -0
  185. package/docs/packages/tauri.mdx +63 -0
  186. package/docs/packages/test.mdx +108 -0
  187. package/docs/packages/vite-plugin.mdx +96 -0
  188. package/docs/packages.mdx +217 -0
  189. package/docs/platform-integration.md +33 -26
  190. package/docs/predicates.mdx +179 -0
  191. package/docs/quickstart.mdx +211 -0
  192. package/docs/recipes.mdx +242 -0
  193. package/docs/skill-file.mdx +105 -0
  194. package/docs/state-management.mdx +197 -0
  195. package/docs/style.css +14 -0
  196. package/docs/system-map.md +47 -32
  197. package/docs/telemetry-contract.md +93 -50
  198. package/docs/telemetry-events.mdx +430 -0
  199. package/docs/telemetry.md +57 -52
  200. package/docs/testing.md +10 -6
  201. package/docs/token-efficiency.md +30 -26
  202. package/docs/tools-act-and-wait.mdx +123 -0
  203. package/docs/tools-act-sequence.mdx +120 -0
  204. package/docs/tools-act.mdx +82 -0
  205. package/docs/tools-assert.mdx +70 -0
  206. package/docs/tools-console.mdx +59 -0
  207. package/docs/tools-inspect.mdx +85 -0
  208. package/docs/tools-navigate.mdx +63 -0
  209. package/docs/tools-network.mdx +86 -0
  210. package/docs/tools-observe.mdx +89 -0
  211. package/docs/tools-overview.mdx +157 -0
  212. package/docs/tools-query.mdx +82 -0
  213. package/docs/tools-session-and-feedback.mdx +91 -0
  214. package/docs/tools-sessions.mdx +65 -0
  215. package/docs/tools-snapshot.mdx +75 -0
  216. package/docs/tools-state.mdx +81 -0
  217. package/docs/tools-tools-and-run.mdx +125 -0
  218. package/docs/tools-wait-for.mdx +66 -0
  219. package/docs/troubleshooting.mdx +193 -0
  220. package/docs/usage.md +175 -153
  221. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  222. package/docs/vs-playwright-mcp.mdx +85 -0
  223. package/docs/vs-screenshots.mdx +96 -0
  224. package/docs/what-is-reticle.mdx +63 -0
  225. package/docs/why-reticle.mdx +107 -0
  226. package/package.json +2 -2
  227. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  228. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  229. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  230. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  231. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  232. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  233. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  234. package/docs/matrix/MATRIX.md +0 -26
  235. package/docs/matrix/README.md +0 -59
  236. package/docs/mint.json +0 -61
@@ -69,7 +69,7 @@ export function buildDynamicTools(allTools, profile) {
69
69
  // `full` advertises all 46 tools DIRECTLY and carries no meta-tools at all, so
70
70
  // reticle_run does not exist there. Saying otherwise sends the agent to a tool the
71
71
  // profile does not have.
72
- note: `The profile is read once at daemon startup — change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. Under this profile every unadvertised tool is reachable through reticle_run; under \`full\` there are no meta-tools because every tool is advertised directly.`,
72
+ note: `The profile is read once at daemon startup: change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. Under this profile every unadvertised tool is reachable through reticle_run; under \`full\` there are no meta-tools because every tool is advertised directly.`,
73
73
  },
74
74
  };
75
75
  const reticleTools = {
@@ -8,6 +8,7 @@
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 { RefusalReason } from '@reticlehq/core';
11
12
  /** The recovery hints, named so they are not free strings and can be asserted in tests. */
12
13
  export declare const RECOVERY: {
13
14
  readonly NO_SESSION: string;
@@ -40,6 +41,15 @@ export declare const RECOVERY: {
40
41
  * saying a server is running and the recovery saying to go start one.
41
42
  */
42
43
  export declare function recoveryFor(message: string): string | undefined;
44
+ /**
45
+ * WHY a tool refused, as one of the six buckets in core's `RefusalReason`.
46
+ *
47
+ * Reads the SAME rules `buildErrorPayload` reads, in the same order, so the reason we report and the
48
+ * advice the agent got can never describe different things. Anything unrecognised is `other`, which
49
+ * is the bucket that says the classifier has a blind spot — and a growing `other` is a finding about
50
+ * this table rather than about anybody's app.
51
+ */
52
+ export declare function refusalReasonFor(rawMessage: string): RefusalReason;
43
53
  /**
44
54
  * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
45
55
  *
@@ -8,7 +8,7 @@
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
13
  import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
14
14
  /** Marks where an over-long message had its middle removed. Named so tests assert it by value. */
@@ -42,17 +42,17 @@ function capMessage(message) {
42
42
  * the agent precisely what to do.
43
43
  */
44
44
  const SELF_RECOVERING = [
45
- /cannot drive native input/i,
45
+ { match: /cannot drive native input/i, reason: RefusalReason.UNSUPPORTED },
46
46
  // The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
47
47
  // the current ids" to a message that just named them contradicts the shorter path it offers, and
48
48
  // a contradicted instruction is how one agent came to retry a dead id twelve times.
49
- /Connected right now:/i,
49
+ { match: /Connected right now:/i, reason: RefusalReason.NO_SESSION },
50
50
  ];
51
51
  /** A message that already carries its own concrete next action, so nothing should be appended. */
52
52
  function isSelfRecovering(message) {
53
53
  if (message.includes(SELF_RECOVERING_MARKER))
54
54
  return true;
55
- return SELF_RECOVERING.some((pattern) => pattern.test(message));
55
+ return SELF_RECOVERING.some((rule) => rule.match.test(message));
56
56
  }
57
57
  /** The recovery hints, named so they are not free strings and can be asserted in tests. */
58
58
  export const RECOVERY = {
@@ -137,6 +137,39 @@ export const RECOVERY = {
137
137
  '` if that is what is missing. Meanwhile drive ' +
138
138
  'a tab the human already has open — reticle_sessions lists them.',
139
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]]));
140
173
  /** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */
141
174
  const RULES = [
142
175
  { match: /no browser session connected/i, hint: RECOVERY.NO_SESSION },
@@ -216,6 +249,33 @@ export function recoveryFor(message) {
216
249
  }
217
250
  return undefined;
218
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
+ }
219
279
  /**
220
280
  * A schema rejection: the call named a parameter that does not exist, or gave one the wrong type.
221
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,12 +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';
11
14
  import { EnvelopeKey } from './tool-kit.js';
12
15
  import { ReticleTool } from './tool-names.js';
13
16
  import { takeFeedbackPrompt } from './feedback-tools.js';
14
17
  import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
18
+ import { noteRefsMinted, wrongTabRefusal } from '../session/ref-provenance.js';
15
19
  import { span } from '../trace.js';
16
20
  import { frictionOf, inviteFor } from './feedback-invite.js';
17
21
  /**
@@ -30,6 +34,20 @@ const ACTION_TOOLS = new Set([
30
34
  ReticleTool.ACT_SEQUENCE,
31
35
  ReticleTool.ACT_AND_WAIT,
32
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
+ ]);
33
51
  export const SESSION_BOUND_TOOLS = new Set([
34
52
  ReticleTool.SNAPSHOT,
35
53
  ReticleTool.QUERY,
@@ -134,6 +152,54 @@ function reportBugsFound(toolName, result) {
134
152
  });
135
153
  }
136
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
+ }
137
203
  /**
138
204
  * The single entry point both the MCP server and the programmatic invoker call instead of
139
205
  * `tool.handler` directly. Runs the handler, then — for a live-session tool returning a plain
@@ -189,11 +255,13 @@ export async function runTool(tool, deps, args) {
189
255
  // prevents: the agent learns its argument was too big, which is something it can fix.
190
256
  const oversized = firstOversizedArg(args);
191
257
  if (oversized !== undefined) {
192
- return {
193
- error: `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
194
- 'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
195
- 'value — a selector or an id rather than a document, or a slice of the text you need.',
196
- };
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 };
197
265
  }
198
266
  // An ACTION is what gets abandoned. Counted here, against verifications, so "the agent drove the
199
267
  // page and then wandered off" becomes a number instead of an impression.
@@ -218,6 +286,25 @@ export async function runTool(tool, deps, args) {
218
286
  session = undefined;
219
287
  }
220
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
+ }
221
308
  const leaseId = session?.id ?? rawSessionId;
222
309
  if (leaseId !== undefined)
223
310
  deps.pool?.touch(leaseId);
@@ -228,6 +315,22 @@ export async function runTool(tool, deps, args) {
228
315
  // nests under it. Free when off — see trace.ts.
229
316
  raw = await span('tool.handler', { tool: tool.name, session: session?.id }, () => tool.handler(deps, args));
230
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
+ }
231
334
  finally {
232
335
  // In a `finally` so a THROWN call still settles: otherwise every failing tool would leak a
233
336
  // concurrency slot and peakConcurrentTools would climb forever on an unhealthy session.
@@ -244,6 +347,20 @@ export async function runTool(tool, deps, args) {
244
347
  recordVerification(tool.name, raw, Date.now() - startedAt, session?.brand);
245
348
  reportBugsFound(tool.name, raw);
246
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
+ }
247
364
  const prompt = isPlainObject(raw) ? takeFeedbackPrompt(tool.name) : undefined;
248
365
  // Same one-shot channel as the feedback ask: the agent is mid-task, so anything it would have to go
249
366
  // and ASK for is something it will never ask for. Spliced on ANY tool result, not just a
@@ -259,10 +376,26 @@ export async function runTool(tool, deps, args) {
259
376
  // report announced as accepted and then silently lost is the failure the awaited send existed to
260
377
  // prevent. See feedback-delivery.ts.
261
378
  const undelivered = isPlainObject(raw) ? takeFeedbackUndelivered() : undefined;
262
- 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
263
395
  ? raw
264
396
  : {
265
397
  ...raw,
398
+ ...(friction !== undefined ? { [EnvelopeKey.FEEDBACK_INVITE]: inviteFor(friction) } : {}),
266
399
  ...(prompt !== undefined ? { [EnvelopeKey.FEEDBACK_PROMPT]: prompt } : {}),
267
400
  ...(update !== undefined ? { [EnvelopeKey.UPDATE_AVAILABLE]: update } : {}),
268
401
  ...(skew !== undefined ? { [EnvelopeKey.VERSION_SKEW]: skew } : {}),
@@ -298,22 +431,5 @@ export async function runTool(tool, deps, args) {
298
431
  const unverified = getSessionMetrics().takeUnverifiedNudge();
299
432
  if (unverified !== undefined)
300
433
  envelope[EnvelopeKey.VERIFY_NEXT] = unverified;
301
- // Invite feedback at the moment of friction, worded for what just happened. Feedback produced
302
- // ~11 of this release's fixes from 14 reports; the handshake instruction is what got those 14,
303
- // and this closes the gap it leaves — the agent mid-problem, not thinking about feedback tooling.
304
- // Counted so `feedback_submitted / feedbackPrompted` can tell us whether it works at all.
305
- const friction = frictionOf({
306
- unknownTool: false,
307
- verifiedUnknown: 'unknown' === result.verified,
308
- repeatRun: getSessionMetrics().currentRun,
309
- errored: 'error' in result,
310
- // A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
311
- // loop this release exists to encourage — get told they are stuck.
312
- producedVerdict: 'verified' in result,
313
- });
314
- if (friction !== undefined) {
315
- envelope[EnvelopeKey.FEEDBACK_INVITE] = inviteFor(friction);
316
- getSessionMetrics().recordFeedbackPrompt();
317
- }
318
434
  return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
319
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>;