@reticlehq/server 2.7.0 → 2.9.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 (357) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +130 -778
  3. package/dist/bridge/bridge.d.ts +23 -0
  4. package/dist/bridge/bridge.js +80 -2
  5. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  6. package/dist/bridge/bridge.test-harness.js +6 -4
  7. package/dist/capsule/causal-summary.d.ts +36 -1
  8. package/dist/capsule/causal-summary.js +33 -7
  9. package/dist/capsule/divergence.js +7 -1
  10. package/dist/cli/chromium-hint.d.ts +33 -5
  11. package/dist/cli/chromium-hint.js +82 -12
  12. package/dist/cli/cli-doctor.js +65 -34
  13. package/dist/cli/cli-flow-commands.js +8 -8
  14. package/dist/cli/cli-kill.d.ts +58 -0
  15. package/dist/cli/cli-kill.js +162 -0
  16. package/dist/cli/cli-parse.d.ts +16 -1
  17. package/dist/cli/cli-parse.js +32 -9
  18. package/dist/cli/cli-port.d.ts +32 -0
  19. package/dist/cli/cli-port.js +82 -35
  20. package/dist/cli/cli-verify.d.ts +21 -0
  21. package/dist/cli/cli-verify.js +47 -3
  22. package/dist/cli/config-discovery.d.ts +35 -0
  23. package/dist/cli/config-discovery.js +163 -0
  24. package/dist/cli/doctor-daemon-line.js +4 -1
  25. package/dist/cli/doctor-project-line.d.ts +37 -0
  26. package/dist/cli/doctor-project-line.js +35 -0
  27. package/dist/cli/doctor-rows.d.ts +51 -0
  28. package/dist/cli/doctor-rows.js +54 -0
  29. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  30. package/dist/cli/doctor-sessions-line.js +69 -0
  31. package/dist/cli/drive-attach.d.ts +99 -0
  32. package/dist/cli/drive-attach.js +203 -0
  33. package/dist/cli/drive-command.d.ts +13 -0
  34. package/dist/cli/drive-command.js +103 -0
  35. package/dist/cli/open-note.d.ts +20 -0
  36. package/dist/cli/open-note.js +37 -0
  37. package/dist/cli/port-holder.d.ts +10 -0
  38. package/dist/cli/port-holder.js +26 -2
  39. package/dist/cli/status-next-action.d.ts +37 -0
  40. package/dist/cli/status-next-action.js +61 -0
  41. package/dist/cli.js +119 -39
  42. package/dist/crawl/crawl-tools.js +12 -2
  43. package/dist/crawl/crawl.d.ts +13 -0
  44. package/dist/crawl/crawl.js +50 -2
  45. package/dist/events/contradictions.d.ts +46 -0
  46. package/dist/events/contradictions.js +35 -22
  47. package/dist/events/declared.d.ts +45 -0
  48. package/dist/events/declared.js +73 -0
  49. package/dist/events/echo-mismatch.d.ts +8 -2
  50. package/dist/events/echo-mismatch.js +33 -5
  51. package/dist/events/event-filters.d.ts +1 -1
  52. package/dist/events/event-filters.js +7 -4
  53. package/dist/events/predicate-eval.d.ts +17 -72
  54. package/dist/events/predicate-eval.js +117 -236
  55. package/dist/events/predicate-parse.js +31 -3
  56. package/dist/events/predicate-route.d.ts +32 -0
  57. package/dist/events/predicate-route.js +102 -0
  58. package/dist/events/predicate-schema.d.ts +99 -0
  59. package/dist/events/predicate-schema.js +309 -0
  60. package/dist/events/predicate.d.ts +8 -0
  61. package/dist/events/predicate.js +296 -22
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-classify.js +1 -1
  69. package/dist/flows/flow-replay-run.js +8 -1
  70. package/dist/flows/flow-tools.js +21 -5
  71. package/dist/flows/heal-run.js +1 -1
  72. package/dist/flows/replay.js +3 -0
  73. package/dist/honesty/accepted-write.d.ts +3 -0
  74. package/dist/honesty/accepted-write.js +8 -3
  75. package/dist/honesty/already-true.js +15 -0
  76. package/dist/honesty/blind-spots.js +10 -2
  77. package/dist/honesty/honesty.d.ts +11 -0
  78. package/dist/honesty/honesty.js +1 -0
  79. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  80. package/dist/honesty/uncaptured-bodies.js +9 -2
  81. package/dist/honesty/unread-outcome.d.ts +8 -0
  82. package/dist/honesty/unread-outcome.js +36 -9
  83. package/dist/honesty/unsettled.d.ts +49 -0
  84. package/dist/honesty/unsettled.js +82 -0
  85. package/dist/honesty/verified.d.ts +36 -3
  86. package/dist/honesty/verified.js +54 -11
  87. package/dist/http-server.d.ts +19 -0
  88. package/dist/http-server.js +202 -4
  89. package/dist/impact/impact-recorder.d.ts +23 -0
  90. package/dist/impact/impact-recorder.js +76 -0
  91. package/dist/impact/impact-store.d.ts +47 -0
  92. package/dist/impact/impact-store.js +171 -0
  93. package/dist/index.d.ts +7 -0
  94. package/dist/index.js +47 -10
  95. package/dist/init/agent-rules.d.ts +36 -0
  96. package/dist/init/agent-rules.js +132 -22
  97. package/dist/init/astro-patch.d.ts +2 -1
  98. package/dist/init/astro-patch.js +38 -6
  99. package/dist/init/capabilities.d.ts +22 -0
  100. package/dist/init/capabilities.js +76 -0
  101. package/dist/init/confirm.d.ts +114 -0
  102. package/dist/init/confirm.js +165 -0
  103. package/dist/init/connect-steps.d.ts +2 -0
  104. package/dist/init/connect-steps.js +50 -0
  105. package/dist/init/cra.js +3 -2
  106. package/dist/init/csp-check.d.ts +38 -0
  107. package/dist/init/csp-check.js +92 -0
  108. package/dist/init/csp-doctor.d.ts +25 -0
  109. package/dist/init/csp-doctor.js +49 -0
  110. package/dist/init/cursor.js +6 -9
  111. package/dist/init/detect.d.ts +8 -0
  112. package/dist/init/detect.js +33 -3
  113. package/dist/init/mcp-clients.d.ts +1 -0
  114. package/dist/init/mcp-clients.js +51 -7
  115. package/dist/init/mcp.d.ts +15 -0
  116. package/dist/init/mcp.js +19 -0
  117. package/dist/init/non-js-project.js +29 -5
  118. package/dist/init/plan-framework.d.ts +10 -0
  119. package/dist/init/plan-framework.js +78 -16
  120. package/dist/init/plan.d.ts +47 -6
  121. package/dist/init/plan.js +228 -69
  122. package/dist/init/run.d.ts +24 -2
  123. package/dist/init/run.js +157 -45
  124. package/dist/init/slash-command.d.ts +1 -1
  125. package/dist/init/slash-command.js +54 -6
  126. package/dist/init/snippets.d.ts +45 -5
  127. package/dist/init/snippets.js +194 -42
  128. package/dist/init/vite-config.js +53 -5
  129. package/dist/input/network-mock-tools.d.ts +19 -0
  130. package/dist/input/network-mock-tools.js +37 -5
  131. package/dist/input/scroll-find.d.ts +6 -0
  132. package/dist/input/scroll-find.js +17 -2
  133. package/dist/input/scroll-tools.js +4 -0
  134. package/dist/journal/attach-journal.d.ts +9 -1
  135. package/dist/journal/attach-journal.js +13 -2
  136. package/dist/journal/journal-query.d.ts +13 -0
  137. package/dist/journal/journal-query.js +28 -3
  138. package/dist/journal/workspace-gitignore.d.ts +10 -0
  139. package/dist/journal/workspace-gitignore.js +59 -0
  140. package/dist/license/license.js +1 -1
  141. package/dist/mcp/mcp-proxy.d.ts +35 -30
  142. package/dist/mcp/mcp-proxy.js +306 -104
  143. package/dist/mcp/mcp.d.ts +10 -1
  144. package/dist/mcp/mcp.js +70 -34
  145. package/dist/mcp/proxy-handshake.d.ts +82 -1
  146. package/dist/mcp/proxy-handshake.js +109 -2
  147. package/dist/mcp/proxy-lifecycle.d.ts +11 -2
  148. package/dist/mcp/proxy-lifecycle.js +12 -3
  149. package/dist/mcp/proxy-log.d.ts +63 -0
  150. package/dist/mcp/proxy-log.js +132 -0
  151. package/dist/mcp/server-instructions.d.ts +17 -0
  152. package/dist/mcp/server-instructions.js +56 -0
  153. package/dist/mcp/shared-params.d.ts +27 -0
  154. package/dist/mcp/shared-params.js +36 -0
  155. package/dist/pool/browser-pool.d.ts +22 -2
  156. package/dist/pool/browser-pool.js +44 -4
  157. package/dist/project/learned-routes.d.ts +24 -0
  158. package/dist/project/learned-routes.js +63 -0
  159. package/dist/project/project-store.d.ts +6 -0
  160. package/dist/project/project-store.js +25 -1
  161. package/dist/project/reticle-dir.d.ts +2 -0
  162. package/dist/project/reticle-dir.js +1 -0
  163. package/dist/session/connection-memory.d.ts +59 -0
  164. package/dist/session/connection-memory.js +108 -0
  165. package/dist/session/dev-command.d.ts +26 -0
  166. package/dist/session/dev-command.js +89 -0
  167. package/dist/session/dev-server-probe.d.ts +68 -0
  168. package/dist/session/dev-server-probe.js +102 -7
  169. package/dist/session/last-act.d.ts +26 -0
  170. package/dist/session/last-act.js +35 -1
  171. package/dist/session/live-control-tools.js +42 -5
  172. package/dist/session/no-session-diagnosis.d.ts +67 -2
  173. package/dist/session/no-session-diagnosis.js +236 -27
  174. package/dist/session/no-session-next-action.d.ts +46 -0
  175. package/dist/session/no-session-next-action.js +85 -0
  176. package/dist/session/no-session-watch.d.ts +40 -1
  177. package/dist/session/no-session-watch.js +174 -8
  178. package/dist/session/pending-commands.d.ts +12 -0
  179. package/dist/session/pending-commands.js +13 -1
  180. package/dist/session/ref-provenance.d.ts +54 -0
  181. package/dist/session/ref-provenance.js +61 -0
  182. package/dist/session/session-lease.js +1 -1
  183. package/dist/session/session-manager.d.ts +4 -0
  184. package/dist/session/session-manager.js +35 -5
  185. package/dist/session/session.d.ts +10 -1
  186. package/dist/session/session.js +146 -5
  187. package/dist/telemetry/app-instrumented.d.ts +8 -0
  188. package/dist/telemetry/app-instrumented.js +10 -0
  189. package/dist/telemetry/automation-hint.d.ts +17 -0
  190. package/dist/telemetry/automation-hint.js +71 -0
  191. package/dist/telemetry/bug-found.d.ts +15 -17
  192. package/dist/telemetry/bug-found.js +52 -21
  193. package/dist/telemetry/cli-telemetry.js +7 -1
  194. package/dist/telemetry/daemon-telemetry.js +24 -0
  195. package/dist/telemetry/feedback.js +1 -1
  196. package/dist/telemetry/init-telemetry.js +9 -1
  197. package/dist/telemetry/install-source.d.ts +56 -0
  198. package/dist/telemetry/install-source.js +74 -0
  199. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  200. package/dist/telemetry/instrumentation-stall.js +90 -0
  201. package/dist/telemetry/run-telemetry.js +4 -1
  202. package/dist/telemetry/session-metrics.d.ts +8 -0
  203. package/dist/telemetry/session-metrics.js +15 -0
  204. package/dist/telemetry/telemetry.d.ts +7 -1
  205. package/dist/telemetry/telemetry.js +26 -2
  206. package/dist/telemetry/tool-refused.d.ts +19 -0
  207. package/dist/telemetry/tool-refused.js +55 -0
  208. package/dist/telemetry/verification-of.js +46 -7
  209. package/dist/temp-dir.d.ts +27 -0
  210. package/dist/temp-dir.js +46 -0
  211. package/dist/tools/act-tools.js +209 -28
  212. package/dist/tools/assert-grade.d.ts +1 -1
  213. package/dist/tools/assert-grade.js +27 -5
  214. package/dist/tools/assert-verdict.js +49 -7
  215. package/dist/tools/browser-tools.js +10 -0
  216. package/dist/tools/contract-tools.js +4 -0
  217. package/dist/tools/dynamic-tools.js +16 -7
  218. package/dist/tools/error-recovery.d.ts +10 -0
  219. package/dist/tools/error-recovery.js +73 -9
  220. package/dist/tools/feedback-invite.js +1 -1
  221. package/dist/tools/invoke-tool.js +159 -26
  222. package/dist/tools/lease-hint.d.ts +30 -8
  223. package/dist/tools/lease-hint.js +65 -14
  224. package/dist/tools/lease-tools.d.ts +40 -1
  225. package/dist/tools/lease-tools.js +181 -83
  226. package/dist/tools/navigate-result.js +1 -1
  227. package/dist/tools/observe-tools.js +10 -4
  228. package/dist/tools/observed-nothing.js +1 -1
  229. package/dist/tools/react-grace.d.ts +52 -0
  230. package/dist/tools/react-grace.js +90 -0
  231. package/dist/tools/read-tools.js +67 -5
  232. package/dist/tools/resolve-target.d.ts +33 -0
  233. package/dist/tools/resolve-target.js +53 -0
  234. package/dist/tools/settle-in-flight.d.ts +94 -0
  235. package/dist/tools/settle-in-flight.js +146 -0
  236. package/dist/tools/snapshot-delta.d.ts +10 -0
  237. package/dist/tools/snapshot-delta.js +194 -9
  238. package/dist/tools/tool-kit.d.ts +8 -0
  239. package/dist/tools/tool-surface.d.ts +60 -0
  240. package/dist/tools/tool-surface.js +86 -1
  241. package/dist/tools/tools.js +61 -8
  242. package/dist/tools/unadvertised-help.js +9 -6
  243. package/docs/README.md +55 -11
  244. package/docs/actions.mdx +120 -0
  245. package/docs/agent-cheatsheet.md +95 -29
  246. package/docs/architecture.md +52 -35
  247. package/docs/benchmarks.md +127 -75
  248. package/docs/best-practices.mdx +216 -0
  249. package/docs/capabilities.mdx +534 -0
  250. package/docs/cli/affected.mdx +53 -0
  251. package/docs/cli/capsules.mdx +70 -0
  252. package/docs/cli/cloud.mdx +153 -0
  253. package/docs/cli/doctor.mdx +109 -0
  254. package/docs/cli/drive.mdx +74 -0
  255. package/docs/cli/feedback.mdx +67 -0
  256. package/docs/cli/gate.mdx +64 -0
  257. package/docs/cli/help.mdx +94 -0
  258. package/docs/cli/hunt.mdx +73 -0
  259. package/docs/cli/identify.mdx +76 -0
  260. package/docs/cli/init.mdx +121 -0
  261. package/docs/cli/kill.mdx +91 -0
  262. package/docs/cli/license.mdx +48 -0
  263. package/docs/cli/mcp.mdx +61 -0
  264. package/docs/cli/open.mdx +74 -0
  265. package/docs/cli/restart.mdx +60 -0
  266. package/docs/cli/rollback.mdx +35 -0
  267. package/docs/cli/serve.mdx +69 -0
  268. package/docs/cli/status.mdx +88 -0
  269. package/docs/cli/stop.mdx +64 -0
  270. package/docs/cli/telemetry.mdx +57 -0
  271. package/docs/cli/update.mdx +52 -0
  272. package/docs/cli/verify.mdx +76 -0
  273. package/docs/cli/version.mdx +39 -0
  274. package/docs/cli/watch.mdx +62 -0
  275. package/docs/cli.mdx +241 -0
  276. package/docs/debugging.md +16 -10
  277. package/docs/deploy-checks.md +45 -12
  278. package/docs/desktop.mdx +384 -0
  279. package/docs/docs.json +364 -0
  280. package/docs/enterprise.md +22 -16
  281. package/docs/faq.mdx +102 -0
  282. package/docs/fixtures.md +13 -7
  283. package/docs/flows.md +29 -23
  284. package/docs/for-agents.md +71 -0
  285. package/docs/frameworks.mdx +238 -0
  286. package/docs/gate-plan.md +48 -42
  287. package/docs/gates.md +28 -22
  288. package/docs/getting-started.md +129 -77
  289. package/docs/human-control.md +24 -18
  290. package/docs/index.mdx +143 -0
  291. package/docs/install-agentic.mdx +224 -0
  292. package/docs/install-manual.mdx +196 -0
  293. package/docs/instrumentation.mdx +238 -0
  294. package/docs/integration-patterns.md +41 -35
  295. package/docs/local-registry.md +19 -13
  296. package/docs/multi-agent-testing.md +19 -13
  297. package/docs/packages/babel-plugin.mdx +79 -0
  298. package/docs/packages/browser.mdx +111 -0
  299. package/docs/packages/core.mdx +116 -0
  300. package/docs/packages/electron.mdx +85 -0
  301. package/docs/packages/eslint-plugin.mdx +86 -0
  302. package/docs/packages/next.mdx +89 -0
  303. package/docs/packages/react.mdx +91 -0
  304. package/docs/packages/server.mdx +98 -0
  305. package/docs/packages/tauri.mdx +63 -0
  306. package/docs/packages/test.mdx +119 -0
  307. package/docs/packages/vite-plugin.mdx +94 -0
  308. package/docs/packages.mdx +218 -0
  309. package/docs/platform-integration.md +35 -26
  310. package/docs/predicates.mdx +248 -0
  311. package/docs/quickstart.mdx +257 -0
  312. package/docs/recipes.mdx +282 -0
  313. package/docs/skill-file.mdx +107 -0
  314. package/docs/state-management.mdx +199 -0
  315. package/docs/style.css +14 -0
  316. package/docs/system-map.md +50 -33
  317. package/docs/telemetry-contract.md +106 -52
  318. package/docs/telemetry-events.mdx +475 -0
  319. package/docs/telemetry.md +63 -55
  320. package/docs/testing.md +44 -6
  321. package/docs/token-efficiency.md +37 -27
  322. package/docs/tools-act-and-wait.mdx +173 -0
  323. package/docs/tools-act-sequence.mdx +137 -0
  324. package/docs/tools-act.mdx +95 -0
  325. package/docs/tools-assert.mdx +129 -0
  326. package/docs/tools-console.mdx +63 -0
  327. package/docs/tools-inspect.mdx +88 -0
  328. package/docs/tools-navigate.mdx +99 -0
  329. package/docs/tools-network.mdx +119 -0
  330. package/docs/tools-observe.mdx +145 -0
  331. package/docs/tools-overview.mdx +160 -0
  332. package/docs/tools-query.mdx +126 -0
  333. package/docs/tools-session-and-feedback.mdx +112 -0
  334. package/docs/tools-sessions.mdx +94 -0
  335. package/docs/tools-snapshot.mdx +129 -0
  336. package/docs/tools-state.mdx +104 -0
  337. package/docs/tools-tools-and-run.mdx +156 -0
  338. package/docs/tools-wait-for.mdx +85 -0
  339. package/docs/troubleshooting.mdx +263 -0
  340. package/docs/usage.md +209 -165
  341. package/docs/vs-chrome-devtools-mcp.mdx +123 -0
  342. package/docs/vs-playwright-mcp.mdx +125 -0
  343. package/docs/vs-screenshots.mdx +137 -0
  344. package/docs/what-is-reticle.mdx +63 -0
  345. package/docs/why-reticle.mdx +109 -0
  346. package/package.json +5 -5
  347. package/docs/desktop-apps.md +0 -256
  348. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  349. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  350. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  351. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  352. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  353. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  354. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  355. package/docs/matrix/MATRIX.md +0 -26
  356. package/docs/matrix/README.md +0 -59
  357. package/docs/mint.json +0 -61
@@ -39,6 +39,12 @@ export const BROWSER_TOOLS = [
39
39
  // reload:true is the absorbed reticle_refresh — same command, one fewer advertised tool.
40
40
  if (true === args['reload']) {
41
41
  const before = deps.sessions.resolve(asString(args['sessionId']));
42
+ // The verdict floor moves HERE, before the page goes away. A reload destroys the document,
43
+ // so every request that failed under the old one belongs to a page that no longer exists —
44
+ // and a later assert defaulting its window to "the last thing that happened" must not reach
45
+ // back past this point. Reported from the field as an assertion whose clauses all passed
46
+ // coming back `contradicted` by hundreds of 500s against resources that were already gone.
47
+ before.lastAct.markNavigated(before.elapsed());
42
48
  await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.REFRESH, {
43
49
  hard: true === args['hard'],
44
50
  });
@@ -65,6 +71,10 @@ export const BROWSER_TOOLS = [
65
71
  // reconnects), but the action record itself — "navigated to X" — is the causal fact worth keeping.
66
72
  const session = deps.sessions.resolve(asString(args['sessionId']));
67
73
  session.beginAction(ReticleTool.NAVIGATE, { url });
74
+ // Same floor as the reload path above, for the same reason: going to a new URL replaces the
75
+ // document just as thoroughly. `beginAction` attributes events to this action; it does not move
76
+ // the window a later assert judges over, and those are two different jobs.
77
+ session.lastAct.markNavigated(session.elapsed());
68
78
  try {
69
79
  const result = (await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.NAVIGATE, { url }));
70
80
  // `ok` is the browser accepting the instruction, not the page arriving — see navigate-result.
@@ -38,6 +38,10 @@ export const CONTRACT_TOOLS = [
38
38
  {
39
39
  name: ReticleTool.CAPABILITIES,
40
40
  description: 'The app-advertised testable surface (reticle.describe): testids, signals, stores, and named flows. Call this first to learn what to assert on without reading source. Pass { fromDisk:true } to read the git-checked .reticle/contract.json instead of the live session (a fresh agent can learn the surface with no browser attached).',
41
+ // Every core tool carries one, so an agent reading the surface never has to guess the shape.
42
+ // `fromDisk` is the example deliberately: it is the call that works with nothing attached, which
43
+ // is the state a fresh agent is actually in when it first reads this list.
44
+ example: { [FROM_DISK_ARG]: true },
41
45
  inputSchema: { [FROM_DISK_ARG]: z.boolean().optional(), ...sessionIdShape },
42
46
  outputSchema: {
43
47
  testids: z.array(z.string()),
@@ -65,11 +65,12 @@ export function buildDynamicTools(allTools, profile) {
65
65
  : {
66
66
  profile: {
67
67
  ...profile,
68
- // NOT "every tool is reachable via reticle_run under any profile" — measured false:
69
- // `full` advertises all 46 tools DIRECTLY and carries no meta-tools at all, so
70
- // reticle_run does not exist there. Saying otherwise sends the agent to a tool the
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.`,
68
+ // This used to say `full` carries no meta-tools because it advertised everything
69
+ // directly. That stopped being true when the advertised surface was capped: no surface
70
+ // advertises the whole registry any more, so BOTH meta-tools are on every surface and
71
+ // reticle_run is always the way to the tail. The old wording would now send an agent
72
+ // away from the only tool that can reach half the registry.
73
+ note: `The surface is read once at daemon startup: change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. No surface advertises every tool — the advertised count is capped because editors budget tools across all connected MCP servers. Every tool listed here is callable through reticle_run { tool, args } whether or not it is advertised.`,
73
74
  },
74
75
  };
75
76
  const reticleTools = {
@@ -86,10 +87,18 @@ export function buildDynamicTools(allTools, profile) {
86
87
  ? args['names'].filter((n) => 'string' === typeof n)
87
88
  : undefined;
88
89
  if (names === undefined || 0 === names.length) {
90
+ // The COUNT is stated, not just implied by the array length. An agent that can see 18 tools
91
+ // has no way to know whether that is all of them; being told the registry holds more is what
92
+ // turns "these are the tools" into "these are the tools I was shown".
93
+ const catalog = allTools.map((t) => ({
94
+ name: t.name,
95
+ summary: firstSentence(t.description),
96
+ }));
89
97
  return Promise.resolve({
90
- tools: allTools.map((t) => ({ name: t.name, summary: firstSentence(t.description) })),
98
+ total: catalog.length,
99
+ tools: catalog,
91
100
  ...profileBlock,
92
- next: 'Load params with reticle_tools { names:[…] }, then call reticle_run { tool, args }.',
101
+ next: `All ${catalog.length} tools above are callable, advertised or not. Load full params with reticle_tools { names:[…] }, then call reticle_run { tool, args }.`,
93
102
  });
94
103
  }
95
104
  return Promise.resolve({
@@ -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,24 +42,26 @@ 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 = {
59
- NO_SESSION: 'No app is connected to Reticle. Ask the human to start their app in dev with @reticlehq/browser ' +
60
- 'enabled, then run `reticle status` to confirm a session appears. If the app is running but no ' +
61
- 'session shows, the SDK is not reaching the bridge — check the dev server is up and using the ' +
62
- 'configured Reticle port.',
59
+ NO_SESSION: "No app is connected to Reticle. If nothing is serving the app, start the project's own dev " +
60
+ 'script (from its package.json) in the BACKGROUND yourself and tell the human in one line that ' +
61
+ 'it is running and how to stop it — never a second server, never a guessed command, never kill ' +
62
+ 'anything. Then run `reticle status` to confirm a session appears. If the app IS running and no ' +
63
+ 'session shows, the SDK is not reaching the bridge — check @reticlehq/browser is loaded in the ' +
64
+ 'page and that both sides use the configured Reticle port.',
63
65
  MULTIPLE_SESSIONS: 'Several tabs are connected. Call reticle_sessions to list them, then pass an explicit sessionId to ' +
64
66
  'target the one you mean.',
65
67
  UNKNOWN_SESSION: 'That sessionId is not connected. Call reticle_sessions for the current ids and retry with a valid one.',
@@ -121,7 +123,9 @@ export const RECOVERY = {
121
123
  COMMAND_TIMEOUT: 'The page did not answer within the command window. That is a fact about the page, not a Reticle ' +
122
124
  'failure: check reticle_sessions for `throttled`/`stale` on this session — a backgrounded tab is ' +
123
125
  'throttled and may never answer, so ask the human to bring it to the front or drive your own ' +
124
- 'browser with reticle_lease. If the tab is in front, the page is busy or blocked (a long ' +
126
+ 'browser with reticle_run { tool: "reticle_lease", action: "acquire", url } — reticle_lease is ' +
127
+ 'not advertised under the default profile, so it is reached through reticle_run, not called ' +
128
+ 'directly. If the tab is in front, the page is busy or blocked (a long ' +
125
129
  'synchronous task, an alert/confirm dialog); reticle_console usually shows what it hit. Retry ' +
126
130
  'once the cause is addressed.',
127
131
  FLOW_STEP_MISSING: 'A recorded step points at an element that is not on the page now. Either the app changed under ' +
@@ -137,6 +141,39 @@ export const RECOVERY = {
137
141
  '` if that is what is missing. Meanwhile drive ' +
138
142
  'a tab the human already has open — reticle_sessions lists them.',
139
143
  };
144
+ /**
145
+ * Which OWNER each recovery belongs to, for `tool_refused`.
146
+ *
147
+ * A `Record` over the recovery keys rather than a second table of patterns, and that is the point:
148
+ * adding a recovery without classifying it does not compile. A parallel regex list would have been
149
+ * correct on the day it was written and silently wrong at the next addition — the drift that has
150
+ * already cost this repo a published number twice, and telemetry drift is not recoverable after the
151
+ * fact.
152
+ */
153
+ const REASON_OF = {
154
+ NO_SESSION: RefusalReason.NO_SESSION,
155
+ MULTIPLE_SESSIONS: RefusalReason.NO_SESSION,
156
+ UNKNOWN_SESSION: RefusalReason.NO_SESSION,
157
+ SCOPE_MISMATCH: RefusalReason.NO_SESSION,
158
+ SESSION_GONE: RefusalReason.NO_SESSION,
159
+ THROTTLED: RefusalReason.NOT_READY,
160
+ COMMAND_TIMEOUT: RefusalReason.NOT_READY,
161
+ NO_POOL: RefusalReason.NOT_READY,
162
+ TOKEN_REQUIRED: RefusalReason.NOT_READY,
163
+ MISSING_BASELINE: RefusalReason.NO_MATCH,
164
+ MISSING_RECORDING: RefusalReason.NO_MATCH,
165
+ STALE_REF: RefusalReason.NO_MATCH,
166
+ NO_SUCH_OPTION: RefusalReason.NO_MATCH,
167
+ FLOW_STEP_MISSING: RefusalReason.NO_MATCH,
168
+ UNSUPPORTED_SURFACE: RefusalReason.UNSUPPORTED,
169
+ NOT_EDITABLE: RefusalReason.UNSUPPORTED,
170
+ CONFIRM_DANGEROUS: RefusalReason.UNSUPPORTED,
171
+ WRONG_TARGET: RefusalReason.UNSUPPORTED,
172
+ BAD_ARGUMENTS: RefusalReason.BAD_ARGS,
173
+ INVALID_NAME: RefusalReason.BAD_ARGS,
174
+ };
175
+ /** Hint text back to its reason. The hints are distinct strings, so this inverts cleanly. */
176
+ const REASON_BY_HINT = new Map(Object.entries(RECOVERY).map(([key, hint]) => [hint, REASON_OF[key]]));
140
177
  /** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */
141
178
  const RULES = [
142
179
  { match: /no browser session connected/i, hint: RECOVERY.NO_SESSION },
@@ -216,6 +253,33 @@ export function recoveryFor(message) {
216
253
  }
217
254
  return undefined;
218
255
  }
256
+ /**
257
+ * WHY a tool refused, as one of the six buckets in core's `RefusalReason`.
258
+ *
259
+ * Reads the SAME rules `buildErrorPayload` reads, in the same order, so the reason we report and the
260
+ * advice the agent got can never describe different things. Anything unrecognised is `other`, which
261
+ * is the bucket that says the classifier has a blind spot — and a growing `other` is a finding about
262
+ * this table rather than about anybody's app.
263
+ */
264
+ export function refusalReasonFor(rawMessage) {
265
+ const message = zodArrayAsSentence(rawMessage);
266
+ // The no-session diagnosis inspected the machine and named the cause itself, so it never reaches
267
+ // the recovery table. It is the single largest refusal there is; missing it would gut the metric.
268
+ if (message.includes(SELF_RECOVERING_MARKER))
269
+ return RefusalReason.NO_SESSION;
270
+ for (const rule of SELF_RECOVERING) {
271
+ if (rule.match.test(message))
272
+ return rule.reason;
273
+ }
274
+ // Same order as buildErrorPayload: a schema rejection is the agent's own call, whatever else in
275
+ // the message might also match.
276
+ if (ARGUMENT_REJECTION.test(message))
277
+ return RefusalReason.BAD_ARGS;
278
+ if (INVALID_NAME_REJECTION.test(message))
279
+ return RefusalReason.BAD_ARGS;
280
+ const hint = recoveryFor(message);
281
+ return (hint === undefined ? undefined : REASON_BY_HINT.get(hint)) ?? RefusalReason.OTHER;
282
+ }
219
283
  /**
220
284
  * A schema rejection: the call named a parameter that does not exist, or gave one the wrong type.
221
285
  *
@@ -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 ' +
@@ -1,18 +1,23 @@
1
1
  import { healthEnvelope } from '../session/session-health.js';
2
- import { TelemetryActor, TelemetryEventKind, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
+ import { TelemetryActor, TelemetryEventKind, TRANSPORT_LIMITS, fingerprintFinding, } from '@reticlehq/core';
3
3
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
4
4
  import { getTelemetry } from '../telemetry/telemetry.js';
5
5
  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
- import { bugsInResult } from '../telemetry/bug-found.js';
8
+ import { bugsInResult, routeOf } 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';
20
+ import { deltaForToolResult, impactSnapshot, initImpact, recordImpact, } from '../impact/impact-recorder.js';
16
21
  import { frictionOf, inviteFor } from './feedback-invite.js';
17
22
  /**
18
23
  * The live-session tools whose result MUST carry the
@@ -30,6 +35,20 @@ const ACTION_TOOLS = new Set([
30
35
  ReticleTool.ACT_SEQUENCE,
31
36
  ReticleTool.ACT_AND_WAIT,
32
37
  ]);
38
+ /**
39
+ * Tools whose result hands the agent refs. The tab they were minted in is remembered, so the call
40
+ * that spends one cannot be routed to a different tab without saying so — see ref-provenance.ts.
41
+ *
42
+ * Over-inclusion is the safe direction here and under-inclusion is not dangerous either: both ends
43
+ * produce a REFUSAL naming the two tabs, never a silent drive against the wrong one.
44
+ */
45
+ const REF_MINTING_TOOLS = new Set([
46
+ ReticleTool.SNAPSHOT,
47
+ ReticleTool.QUERY,
48
+ ReticleTool.INSPECT,
49
+ ReticleTool.EXPLORE,
50
+ ReticleTool.CRAWL,
51
+ ]);
33
52
  export const SESSION_BOUND_TOOLS = new Set([
34
53
  ReticleTool.SNAPSHOT,
35
54
  ReticleTool.QUERY,
@@ -123,17 +142,70 @@ function reportBugsFound(toolName, result) {
123
142
  if (0 === bugs.length)
124
143
  return;
125
144
  const metrics = getSessionMetrics();
145
+ const route = routeOf(result);
126
146
  for (const bug of bugs) {
127
147
  // `recordBug` answers whether this kind is new to the session, which is the only thing that
128
148
  // separates "another defect" from "the same defect again". Counting instances as defects is how
129
149
  // a published number inflates itself.
130
150
  const first = metrics.recordBug(bug.kind);
151
+ const identity = route !== undefined
152
+ ? { kind: bug.kind, source: bug.source, route }
153
+ : { kind: bug.kind, source: bug.source };
154
+ const fingerprint = fingerprintFinding(identity);
131
155
  void getTelemetry().emit(TelemetryEventKind.BUG_FOUND, {
132
156
  actor: TelemetryActor.AGENT,
133
- bug: { ...bug, repeat: !first },
157
+ bug: { ...bug, repeat: !first, fingerprint },
134
158
  });
135
159
  }
136
160
  }
161
+ /**
162
+ * Report a refused call, at the one place both dispatch paths cross.
163
+ *
164
+ * A refusal arrives two ways and they are the same event: the handler THROWS (the common case — no
165
+ * session, a stale ref, a schema rejection), or it RETURNS `{ error }`, which is this codebase's
166
+ * refusal convention and just as much a refusal. Both are classified by the same table the agent's
167
+ * recovery advice comes from, so the reason we record and the advice it got cannot describe
168
+ * different things.
169
+ *
170
+ * `reticle_run` is skipped for the same reason `bugsInResult` skips it: it is a WRAPPER whose
171
+ * handler calls `runTool` on the real tool, which already reported that refusal under the real
172
+ * tool's name, and then hands the same failure back out to be reported a second time as
173
+ * `reticle_run`. A count that doubles because of HOW a tool was reached is not a measurement.
174
+ */
175
+ function reportRefusal(toolName, message) {
176
+ if (toolName === ReticleTool.RUN)
177
+ return;
178
+ reportToolRefused(toolName, refusalReasonFor(message));
179
+ }
180
+ /**
181
+ * Which friction this result represents, counting the invitation if there is one.
182
+ *
183
+ * Feedback is the highest-yield channel this product has, and `feedbackPrompted` is the denominator
184
+ * that says whether inviting mid-task works or is decoration. It read as near-empty because the
185
+ * count sat behind the session-bound early return, so a session-EXEMPT tool never invited and a tool
186
+ * that THREW never got there — and a throw is the commonest refusal there is.
187
+ *
188
+ * `reticle_run` is skipped for the same reason it is skipped for refusals and defects: the wrapper's
189
+ * handler calls `runTool` on the real tool, which already invited under the real tool's name, and
190
+ * the failure is then handed back out to be counted a second time. A doubled denominator makes the
191
+ * feedback rate read half.
192
+ */
193
+ function frictionInviteFor(toolName, raw) {
194
+ if (!isPlainObject(raw) || toolName === ReticleTool.RUN)
195
+ return undefined;
196
+ const friction = frictionOf({
197
+ unknownTool: false,
198
+ verifiedUnknown: 'unknown' === raw['verified'],
199
+ repeatRun: getSessionMetrics().currentRun,
200
+ errored: resultIsError(raw),
201
+ // A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
202
+ // loop this release exists to encourage — get told they are stuck.
203
+ producedVerdict: 'verified' in raw,
204
+ });
205
+ if (friction !== undefined)
206
+ getSessionMetrics().recordFeedbackPrompt();
207
+ return friction;
208
+ }
137
209
  /**
138
210
  * The single entry point both the MCP server and the programmatic invoker call instead of
139
211
  * `tool.handler` directly. Runs the handler, then — for a live-session tool returning a plain
@@ -173,6 +245,9 @@ export async function runTool(tool, deps, args) {
173
245
  // closure carries this call's own identity, which is also what makes peak-concurrency measurable.
174
246
  // A daemon that has served even one tool call is doing a job for somebody; see daemon-usefulness.
175
247
  noteToolCall();
248
+ // The impact record needs the project's own `.reticle` root, and this is the first place every
249
+ // call knows it. Idempotent: the first root wins for the daemon's lifetime.
250
+ initImpact({ reticleRoot: deps.reticleRoot });
176
251
  // An oversized argument is refused BEFORE the handler deserialises it.
177
252
  //
178
253
  // tool-fuzz failed CI on the invariant that matters most — `every tool answers every hostile call`
@@ -189,11 +264,13 @@ export async function runTool(tool, deps, args) {
189
264
  // prevents: the agent learns its argument was too big, which is something it can fix.
190
265
  const oversized = firstOversizedArg(args);
191
266
  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
- };
267
+ const error = `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
268
+ 'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
269
+ 'value — a selector or an id rather than a document, or a slice of the text you need.';
270
+ // Refused before the handler was ever entered, and still a refusal the agent has to recover
271
+ // from. Reported here rather than at the return below, which this branch never reaches.
272
+ reportRefusal(tool.name, error);
273
+ return { error };
197
274
  }
198
275
  // An ACTION is what gets abandoned. Counted here, against verifications, so "the agent drove the
199
276
  // page and then wandered off" becomes a number instead of an impression.
@@ -218,6 +295,25 @@ export async function runTool(tool, deps, args) {
218
295
  session = undefined;
219
296
  }
220
297
  }
298
+ // A ref that was handed out by a DIFFERENT tab, on a call that named no session. Refused here, at
299
+ // the one point that knows both the ref and the tab resolution picked — the handler only learns
300
+ // that the ref missed, and answers that with "the DOM re-rendered", which is a confident diagnosis
301
+ // of the wrong thing. The other half is worse: the same ref can resolve to a different element in
302
+ // the guessed tab and return a green about a page nobody asked about.
303
+ if (session !== undefined) {
304
+ const wrongTab = wrongTabRefusal({
305
+ ref: asString(args['ref']),
306
+ explicitSessionId: rawSessionId,
307
+ chosenSessionId: session.id,
308
+ connected: () => deps.sessions.list(),
309
+ });
310
+ if (wrongTab !== undefined) {
311
+ // Refused before the handler was entered, so it is reported here — the same shape as the
312
+ // oversized-argument refusal above.
313
+ reportRefusal(tool.name, wrongTab);
314
+ return { error: wrongTab };
315
+ }
316
+ }
221
317
  const leaseId = session?.id ?? rawSessionId;
222
318
  if (leaseId !== undefined)
223
319
  deps.pool?.touch(leaseId);
@@ -228,6 +324,22 @@ export async function runTool(tool, deps, args) {
228
324
  // nests under it. Free when off — see trace.ts.
229
325
  raw = await span('tool.handler', { tool: tool.name, session: session?.id }, () => tool.handler(deps, args));
230
326
  }
327
+ catch (error) {
328
+ // The commonest refusal shape by far, and the one nothing could see: the message is built, handed
329
+ // to the agent by the MCP boundary, and discarded. Reported here rather than at that boundary
330
+ // because there are two of them (mcp.ts and the reticle_run hatch) and this is the one place both
331
+ // go through — a third would otherwise be invisible from the day it was added.
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ reportRefusal(tool.name, message);
334
+ // The invitation on THIS path is the one `buildErrorPayload` attaches when it does not recognise
335
+ // the error, and it was never counted — so the denominator excluded the commonest friction there
336
+ // is. Gated on the payload actually carrying the ask, because a recognised error gets a recovery
337
+ // instead and counting an invitation nobody was shown breaks the ratio in the other direction.
338
+ if (tool.name !== ReticleTool.RUN && buildErrorPayload(message).feedback !== undefined) {
339
+ getSessionMetrics().recordFeedbackPrompt();
340
+ }
341
+ throw error;
342
+ }
231
343
  finally {
232
344
  // In a `finally` so a THROWN call still settles: otherwise every failing tool would leak a
233
345
  // concurrency slot and peakConcurrentTools would climb forever on an unhealthy session.
@@ -244,6 +356,28 @@ export async function runTool(tool, deps, args) {
244
356
  recordVerification(tool.name, raw, Date.now() - startedAt, session?.brand);
245
357
  reportBugsFound(tool.name, raw);
246
358
  }
359
+ // The other half of the refusal surface. A top-level `error` string IS this codebase's refusal
360
+ // convention, so half the tools refuse by RETURNING one rather than throwing — and reading only the
361
+ // throw path would have measured half the wall and called it the whole of it, the same way `isError`
362
+ // once did. A call that was served clears the retry chain, so the next refusal after it is a first
363
+ // refusal rather than a retry of something unrelated.
364
+ // The user's own record of what Reticle did for them. Same chokepoint as everything else that
365
+ // counts, for the same reason: a second recording site is a second thing to forget. Separate
366
+ // store from telemetry - this one never leaves the machine.
367
+ recordImpact(deltaForToolResult(raw, Date.now() - startedAt, resultIsError(raw)));
368
+ // ...and the tab being driven is told, so the report is live rather than a thing you reload.
369
+ // Optional-call, not optional-chain-on-the-object: a test double is a partial Session, and a
370
+ // courtesy push must never be the reason a tool call throws.
371
+ session?.pushImpact?.(impactSnapshot);
372
+ if (resultIsError(raw))
373
+ reportRefusal(tool.name, raw.error);
374
+ else {
375
+ noteToolServed();
376
+ // Which tab these refs belong to. Recorded on the way OUT, so the next call that spends one is
377
+ // measured against the tab that actually produced it.
378
+ if (session !== undefined && REF_MINTING_TOOLS.has(tool.name))
379
+ noteRefsMinted(session.id);
380
+ }
247
381
  const prompt = isPlainObject(raw) ? takeFeedbackPrompt(tool.name) : undefined;
248
382
  // Same one-shot channel as the feedback ask: the agent is mid-task, so anything it would have to go
249
383
  // and ASK for is something it will never ask for. Spliced on ANY tool result, not just a
@@ -259,10 +393,26 @@ export async function runTool(tool, deps, args) {
259
393
  // report announced as accepted and then silently lost is the failure the awaited send existed to
260
394
  // prevent. See feedback-delivery.ts.
261
395
  const undelivered = isPlainObject(raw) ? takeFeedbackUndelivered() : undefined;
262
- const result = prompt === undefined && update === undefined && skew === undefined && undelivered === undefined
396
+ // Invite feedback at the moment of friction, worded for what just happened. Counted, because
397
+ // `feedback_submitted / feedbackPrompted` is the only thing that says whether the invitation works
398
+ // at all rather than being decoration.
399
+ //
400
+ // This used to sit below the session-bound early return, which put it behind two conditions that
401
+ // exclude most friction there is. A session-EXEMPT tool never reached it, and neither did a tool
402
+ // that THREW — and throwing is the commonest refusal shape by a wide margin, i.e. exactly the
403
+ // `refused` friction the line is written for. `errored` could only ever see the handlers that
404
+ // RETURN an error. So the denominator was counting a small, unrepresentative slice of the
405
+ // invitations and reading near-empty, which looks identical to a nudge that never fires.
406
+ const friction = frictionInviteFor(tool.name, raw);
407
+ const result = prompt === undefined &&
408
+ update === undefined &&
409
+ skew === undefined &&
410
+ undelivered === undefined &&
411
+ friction === undefined
263
412
  ? raw
264
413
  : {
265
414
  ...raw,
415
+ ...(friction !== undefined ? { [EnvelopeKey.FEEDBACK_INVITE]: inviteFor(friction) } : {}),
266
416
  ...(prompt !== undefined ? { [EnvelopeKey.FEEDBACK_PROMPT]: prompt } : {}),
267
417
  ...(update !== undefined ? { [EnvelopeKey.UPDATE_AVAILABLE]: update } : {}),
268
418
  ...(skew !== undefined ? { [EnvelopeKey.VERSION_SKEW]: skew } : {}),
@@ -298,22 +448,5 @@ export async function runTool(tool, deps, args) {
298
448
  const unverified = getSessionMetrics().takeUnverifiedNudge();
299
449
  if (unverified !== undefined)
300
450
  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
451
  return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
319
452
  }
@@ -5,13 +5,35 @@
5
5
  * — it does not wait for the human's tab. Measured over a day: lease sessions had a median of 30
6
6
  * tool calls and produced 46% of all bugs found, against a median of 1 call for everything else.
7
7
  *
8
- * When it fails it is almost always a PORT MISMATCH: the tab loads, and the app's SDK dials the port
9
- * it was BUILT with, which need not be the daemon that issued the lease. Verified by driving it —
10
- * against a matching daemon `acquire` returns ready:true and the session is driveable immediately;
11
- * against a different one the tab connects to the OTHER daemon instead.
12
- *
13
8
  * The old hint asked "is <url> running with @reticlehq/core enabled?", which is the one thing that
14
- * cannot be in doubt: the pool just navigated a real browser to it. Naming the true cause first is
15
- * the difference between a two-minute fix and abandoning the best path Reticle has.
9
+ * cannot be in doubt: the pool just navigated a real browser to it. Its replacement then swung to
10
+ * the other confident-but-unevidenced claim — "the usual cause is a PORT MISMATCH" — and the daemon
11
+ * ended up contradicting ITSELF inside one response, saying in `reticle_sessions` that a session had
12
+ * connected earlier "so the wiring is correct" and here that the port was probably wrong. Across a
13
+ * batch of field reports on four apps the port was correct every single time, and `reticle init`
14
+ * would have been the wrong action every single time.
15
+ *
16
+ * So: rank by what the daemon actually knows, and fall back to the differential only when it knows
17
+ * nothing. A cause we hold positive evidence against is not printed at all.
16
18
  */
17
- export declare function leaseNotConnectedHint(url: string, port: number): string;
19
+ /** What the daemon can say for certain at the moment a lease comes back unconnected. */
20
+ export interface LeaseEvidence {
21
+ /** A dial WAS made and turned away, with the reason. The only certain cause there is. */
22
+ refusal?: string;
23
+ /** An app for this project has connected on this port before (durable, survives a restart). */
24
+ previouslyConnected?: boolean;
25
+ /** The project has been through `init` — so recommending it again cannot help. */
26
+ initialized?: boolean;
27
+ /** The framework `.reticle.json` declares, used to rank rather than to decorate. */
28
+ framework?: string;
29
+ /**
30
+ * Whether any Reticle marker was found in the page the lease loaded.
31
+ *
32
+ * The single bit that separates the two commonest cases — "this app ships no SDK" and "the SDK
33
+ * loaded but could not reach the daemon" — which today produce identical messages. Absent means
34
+ * NOT CHECKED, and an absent bit prints nothing: a marker check that could not run must not be
35
+ * reported as a marker that was not found.
36
+ */
37
+ sdkMarker?: boolean;
38
+ }
39
+ export declare function leaseNotConnectedHint(url: string, port: number, evidence?: LeaseEvidence): string;