@reticlehq/server 2.8.0 → 2.10.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 (318) hide show
  1. package/SKILL.md +130 -813
  2. package/dist/bridge/bridge.d.ts +23 -0
  3. package/dist/bridge/bridge.js +80 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  5. package/dist/bridge/bridge.test-harness.js +6 -4
  6. package/dist/capsule/capsule-store.d.ts +2 -2
  7. package/dist/capsule/causal-summary.d.ts +16 -1
  8. package/dist/capsule/causal-summary.js +2 -1
  9. package/dist/cli/cli-doctor.js +41 -13
  10. package/dist/cli/cli-flow-commands.js +8 -8
  11. package/dist/cli/cli-launch.d.ts +0 -17
  12. package/dist/cli/cli-launch.js +0 -28
  13. package/dist/cli/cli-parse.d.ts +1 -1
  14. package/dist/cli/cli-parse.js +1 -1
  15. package/dist/cli/cli-port.d.ts +24 -0
  16. package/dist/cli/cli-port.js +66 -35
  17. package/dist/cli/cli-verify.d.ts +21 -0
  18. package/dist/cli/cli-verify.js +45 -1
  19. package/dist/cli/config-discovery.d.ts +35 -0
  20. package/dist/cli/config-discovery.js +163 -0
  21. package/dist/cli/doctor-daemon-line.js +4 -1
  22. package/dist/cli/doctor-project-line.d.ts +37 -0
  23. package/dist/cli/doctor-project-line.js +35 -0
  24. package/dist/cli/doctor-rows.d.ts +51 -0
  25. package/dist/cli/doctor-rows.js +54 -0
  26. package/dist/cli/doctor-sessions-line.js +6 -3
  27. package/dist/cli/drive-attach.d.ts +99 -0
  28. package/dist/cli/drive-attach.js +203 -0
  29. package/dist/cli/drive-command.d.ts +13 -0
  30. package/dist/cli/drive-command.js +103 -0
  31. package/dist/cli/open-note.d.ts +20 -0
  32. package/dist/cli/open-note.js +37 -0
  33. package/dist/cli/status-next-action.d.ts +37 -0
  34. package/dist/cli/status-next-action.js +61 -0
  35. package/dist/cli.js +65 -70
  36. package/dist/crawl/crawl.d.ts +7 -2
  37. package/dist/crawl/crawl.js +52 -9
  38. package/dist/ee/audit-log.js +2 -2
  39. package/dist/events/body-failures.d.ts +2 -2
  40. package/dist/events/contradiction-folds.d.ts +45 -0
  41. package/dist/events/contradiction-folds.js +42 -0
  42. package/dist/events/contradictions.d.ts +51 -1
  43. package/dist/events/contradictions.js +26 -7
  44. package/dist/events/declared.d.ts +45 -0
  45. package/dist/events/declared.js +73 -0
  46. package/dist/events/echo-mismatch.d.ts +9 -3
  47. package/dist/events/echo-mismatch.js +33 -5
  48. package/dist/events/event-filters.d.ts +1 -1
  49. package/dist/events/event-filters.js +7 -4
  50. package/dist/events/predicate-eval.d.ts +17 -91
  51. package/dist/events/predicate-eval.js +85 -326
  52. package/dist/events/predicate-parse.js +31 -3
  53. package/dist/events/predicate-route.d.ts +32 -0
  54. package/dist/events/predicate-route.js +102 -0
  55. package/dist/events/predicate-schema.d.ts +99 -0
  56. package/dist/events/predicate-schema.js +309 -0
  57. package/dist/events/predicate.d.ts +8 -0
  58. package/dist/events/predicate.js +127 -5
  59. package/dist/events/stale-response.d.ts +2 -3
  60. package/dist/events/stale-response.js +49 -2
  61. package/dist/events/unit-mismatch.d.ts +2 -2
  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-replay-run.js +8 -1
  69. package/dist/flows/flow-tools.js +9 -1
  70. package/dist/flows/replay.js +3 -0
  71. package/dist/flows/verify-change-tools.js +1 -1
  72. package/dist/honesty/accepted-write.d.ts +3 -0
  73. package/dist/honesty/accepted-write.js +8 -3
  74. package/dist/honesty/already-true.js +15 -0
  75. package/dist/honesty/blind-spots.js +4 -0
  76. package/dist/honesty/honesty.d.ts +11 -0
  77. package/dist/honesty/honesty.js +1 -0
  78. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  79. package/dist/honesty/uncaptured-bodies.js +9 -2
  80. package/dist/honesty/unread-outcome.d.ts +8 -0
  81. package/dist/honesty/unread-outcome.js +36 -9
  82. package/dist/honesty/unsettled.d.ts +9 -0
  83. package/dist/honesty/unsettled.js +7 -3
  84. package/dist/honesty/verified.d.ts +29 -3
  85. package/dist/honesty/verified.js +67 -8
  86. package/dist/http-server.d.ts +19 -0
  87. package/dist/http-server.js +202 -4
  88. package/dist/impact/impact-recorder.d.ts +23 -0
  89. package/dist/impact/impact-recorder.js +76 -0
  90. package/dist/impact/impact-store.d.ts +47 -0
  91. package/dist/impact/impact-store.js +171 -0
  92. package/dist/index.d.ts +25 -0
  93. package/dist/index.js +60 -10
  94. package/dist/init/agent-rules.d.ts +20 -0
  95. package/dist/init/agent-rules.js +36 -0
  96. package/dist/init/astro-patch.js +27 -1
  97. package/dist/init/confirm.d.ts +21 -3
  98. package/dist/init/confirm.js +32 -5
  99. package/dist/init/connect-steps.d.ts +2 -0
  100. package/dist/init/connect-steps.js +50 -0
  101. package/dist/init/csp-check.d.ts +38 -0
  102. package/dist/init/csp-check.js +92 -0
  103. package/dist/init/csp-doctor.d.ts +25 -0
  104. package/dist/init/csp-doctor.js +49 -0
  105. package/dist/init/cursor.js +6 -9
  106. package/dist/init/detect.d.ts +8 -0
  107. package/dist/init/detect.js +33 -3
  108. package/dist/init/mcp-clients.d.ts +1 -0
  109. package/dist/init/mcp-clients.js +51 -7
  110. package/dist/init/mcp.d.ts +16 -2
  111. package/dist/init/mcp.js +23 -1
  112. package/dist/init/plan-framework.d.ts +10 -0
  113. package/dist/init/plan-framework.js +25 -1
  114. package/dist/init/plan.d.ts +7 -2
  115. package/dist/init/plan.js +66 -30
  116. package/dist/init/run.js +11 -5
  117. package/dist/init/slash-command.d.ts +1 -1
  118. package/dist/init/slash-command.js +12 -4
  119. package/dist/init/snippets.d.ts +1 -1
  120. package/dist/init/snippets.js +35 -7
  121. package/dist/init/vite-config.js +24 -2
  122. package/dist/input/network-mock-tools.js +8 -1
  123. package/dist/input/scroll-find.d.ts +6 -0
  124. package/dist/input/scroll-find.js +17 -2
  125. package/dist/input/scroll-tools.js +4 -0
  126. package/dist/journal/attach-journal.d.ts +9 -1
  127. package/dist/journal/attach-journal.js +13 -2
  128. package/dist/journal/journal-query.d.ts +13 -0
  129. package/dist/journal/journal-query.js +28 -3
  130. package/dist/journal/workspace-gitignore.d.ts +10 -0
  131. package/dist/journal/workspace-gitignore.js +59 -0
  132. package/dist/license/license.d.ts +69 -4
  133. package/dist/license/license.js +84 -15
  134. package/dist/mcp/mcp-proxy.d.ts +7 -93
  135. package/dist/mcp/mcp-proxy.js +183 -250
  136. package/dist/mcp/mcp.d.ts +27 -2
  137. package/dist/mcp/mcp.js +77 -36
  138. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  139. package/dist/mcp/proxy-daemon-probe.js +48 -0
  140. package/dist/mcp/proxy-handshake.d.ts +82 -1
  141. package/dist/mcp/proxy-handshake.js +110 -3
  142. package/dist/mcp/proxy-lifecycle.d.ts +10 -1
  143. package/dist/mcp/proxy-lifecycle.js +11 -2
  144. package/dist/mcp/proxy-log.d.ts +63 -0
  145. package/dist/mcp/proxy-log.js +132 -0
  146. package/dist/mcp/server-instructions.d.ts +17 -0
  147. package/dist/mcp/server-instructions.js +56 -0
  148. package/dist/mcp/shared-params.d.ts +27 -0
  149. package/dist/mcp/shared-params.js +36 -0
  150. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  151. package/dist/mcp/sse-frame-parser.js +41 -0
  152. package/dist/project/reticle-dir.d.ts +2 -0
  153. package/dist/project/reticle-dir.js +1 -0
  154. package/dist/session/connection-memory.d.ts +59 -0
  155. package/dist/session/connection-memory.js +108 -0
  156. package/dist/session/dev-command.d.ts +26 -0
  157. package/dist/session/dev-command.js +89 -0
  158. package/dist/session/dev-server-probe.d.ts +68 -0
  159. package/dist/session/dev-server-probe.js +102 -7
  160. package/dist/session/last-act.d.ts +26 -0
  161. package/dist/session/last-act.js +35 -1
  162. package/dist/session/live-control-tools.js +42 -5
  163. package/dist/session/no-session-diagnosis.d.ts +55 -2
  164. package/dist/session/no-session-diagnosis.js +199 -22
  165. package/dist/session/no-session-next-action.d.ts +46 -0
  166. package/dist/session/no-session-next-action.js +85 -0
  167. package/dist/session/no-session-watch.d.ts +40 -1
  168. package/dist/session/no-session-watch.js +172 -11
  169. package/dist/session/pending-commands.d.ts +12 -0
  170. package/dist/session/pending-commands.js +13 -1
  171. package/dist/session/session-manager.d.ts +4 -0
  172. package/dist/session/session-manager.js +35 -5
  173. package/dist/session/session-readiness.js +1 -1
  174. package/dist/session/session.d.ts +10 -1
  175. package/dist/session/session.js +146 -5
  176. package/dist/telemetry/app-instrumented.d.ts +8 -0
  177. package/dist/telemetry/app-instrumented.js +10 -0
  178. package/dist/telemetry/bug-found.d.ts +18 -0
  179. package/dist/telemetry/bug-found.js +14 -0
  180. package/dist/telemetry/daemon-telemetry.js +29 -0
  181. package/dist/telemetry/install-source.d.ts +20 -0
  182. package/dist/telemetry/install-source.js +34 -0
  183. package/dist/telemetry/instrumentation-stall.d.ts +57 -0
  184. package/dist/telemetry/instrumentation-stall.js +103 -0
  185. package/dist/telemetry/license-activation.d.ts +48 -0
  186. package/dist/telemetry/license-activation.js +57 -0
  187. package/dist/telemetry/session-metrics.d.ts +8 -0
  188. package/dist/telemetry/session-metrics.js +10 -0
  189. package/dist/telemetry/telemetry.d.ts +3 -1
  190. package/dist/telemetry/telemetry.js +23 -3
  191. package/dist/tools/act-tools.js +166 -29
  192. package/dist/tools/assert-verdict.js +32 -7
  193. package/dist/tools/browser-tools.js +10 -0
  194. package/dist/tools/dynamic-tools.js +17 -8
  195. package/dist/tools/error-recovery.js +10 -6
  196. package/dist/tools/feedback-tools.js +31 -6
  197. package/dist/tools/invoke-tool.d.ts +1 -1
  198. package/dist/tools/invoke-tool.js +30 -8
  199. package/dist/tools/lease-hint.d.ts +30 -8
  200. package/dist/tools/lease-hint.js +65 -14
  201. package/dist/tools/lease-tools.d.ts +11 -0
  202. package/dist/tools/lease-tools.js +141 -96
  203. package/dist/tools/observe-tools.js +9 -3
  204. package/dist/tools/react-grace.d.ts +4 -0
  205. package/dist/tools/react-grace.js +6 -3
  206. package/dist/tools/read-tools.js +47 -1
  207. package/dist/tools/resolve-target.d.ts +33 -0
  208. package/dist/tools/resolve-target.js +53 -0
  209. package/dist/tools/settle-in-flight.d.ts +15 -0
  210. package/dist/tools/settle-in-flight.js +42 -2
  211. package/dist/tools/snapshot-delta.d.ts +3 -0
  212. package/dist/tools/snapshot-delta.js +67 -6
  213. package/dist/tools/tool-kit.d.ts +38 -3
  214. package/dist/tools/tool-names.d.ts +2 -0
  215. package/dist/tools/tool-names.js +2 -0
  216. package/dist/tools/tool-surface.d.ts +60 -0
  217. package/dist/tools/tool-surface.js +84 -19
  218. package/dist/tools/tools.js +61 -5
  219. package/dist/tools/unadvertised-help.js +9 -6
  220. package/docs/README.md +1 -2
  221. package/docs/actions.mdx +41 -8
  222. package/docs/agent-cheatsheet.md +6 -4
  223. package/docs/architecture.md +6 -0
  224. package/docs/benchmarks.md +60 -12
  225. package/docs/best-practices.mdx +11 -6
  226. package/docs/capabilities.mdx +204 -57
  227. package/docs/cli/affected.mdx +6 -4
  228. package/docs/cli/capsules.mdx +9 -8
  229. package/docs/cli/cloud.mdx +30 -4
  230. package/docs/cli/doctor.mdx +41 -8
  231. package/docs/cli/drive.mdx +34 -15
  232. package/docs/cli/gate.mdx +6 -6
  233. package/docs/cli/help.mdx +8 -6
  234. package/docs/cli/hunt.mdx +20 -4
  235. package/docs/cli/identify.mdx +16 -2
  236. package/docs/cli/init.mdx +35 -8
  237. package/docs/cli/kill.mdx +5 -5
  238. package/docs/cli/license.mdx +1 -1
  239. package/docs/cli/open.mdx +6 -0
  240. package/docs/cli/restart.mdx +6 -6
  241. package/docs/cli/serve.mdx +10 -3
  242. package/docs/cli/status.mdx +29 -5
  243. package/docs/cli/stop.mdx +8 -0
  244. package/docs/cli/verify.mdx +20 -1
  245. package/docs/cli/version.mdx +1 -1
  246. package/docs/cli/watch.mdx +9 -2
  247. package/docs/cli.mdx +44 -22
  248. package/docs/debugging.md +3 -1
  249. package/docs/deploy-checks.md +31 -2
  250. package/docs/desktop.mdx +276 -80
  251. package/docs/docs.json +13 -12
  252. package/docs/enterprise.md +9 -3
  253. package/docs/faq.mdx +49 -35
  254. package/docs/fixtures.md +2 -0
  255. package/docs/flows.md +7 -5
  256. package/docs/for-agents.md +3 -3
  257. package/docs/frameworks.mdx +46 -7
  258. package/docs/gate-plan.md +4 -2
  259. package/docs/gates.md +8 -6
  260. package/docs/getting-started.md +53 -12
  261. package/docs/human-control.md +2 -0
  262. package/docs/index.mdx +43 -1
  263. package/docs/install-agentic.mdx +13 -4
  264. package/docs/install-manual.mdx +6 -4
  265. package/docs/instrumentation.mdx +7 -1
  266. package/docs/integration-patterns.md +3 -1
  267. package/docs/local-registry.md +4 -2
  268. package/docs/multi-agent-testing.md +3 -1
  269. package/docs/packages/babel-plugin.mdx +15 -6
  270. package/docs/packages/browser.mdx +2 -2
  271. package/docs/packages/core.mdx +26 -16
  272. package/docs/packages/electron.mdx +5 -7
  273. package/docs/packages/eslint-plugin.mdx +5 -3
  274. package/docs/packages/next.mdx +3 -5
  275. package/docs/packages/react.mdx +3 -3
  276. package/docs/packages/server.mdx +2 -2
  277. package/docs/packages/tauri.mdx +6 -6
  278. package/docs/packages/test.mdx +18 -7
  279. package/docs/packages/vite-plugin.mdx +4 -6
  280. package/docs/packages.mdx +14 -13
  281. package/docs/platform-integration.md +2 -0
  282. package/docs/predicates.mdx +76 -7
  283. package/docs/quickstart.mdx +51 -5
  284. package/docs/recipes.mdx +63 -23
  285. package/docs/skill-file.mdx +3 -1
  286. package/docs/state-management.mdx +2 -0
  287. package/docs/system-map.md +3 -1
  288. package/docs/telemetry-contract.md +33 -5
  289. package/docs/telemetry-events.mdx +63 -18
  290. package/docs/telemetry.md +8 -5
  291. package/docs/testing.md +35 -1
  292. package/docs/token-efficiency.md +8 -2
  293. package/docs/tools-act-and-wait.mdx +64 -14
  294. package/docs/tools-act-sequence.mdx +34 -17
  295. package/docs/tools-act.mdx +24 -11
  296. package/docs/tools-assert.mdx +69 -10
  297. package/docs/tools-console.mdx +11 -7
  298. package/docs/tools-inspect.mdx +11 -8
  299. package/docs/tools-navigate.mdx +41 -5
  300. package/docs/tools-network.mdx +42 -9
  301. package/docs/tools-observe.mdx +66 -10
  302. package/docs/tools-overview.mdx +26 -23
  303. package/docs/tools-query.mdx +52 -8
  304. package/docs/tools-session-and-feedback.mdx +35 -9
  305. package/docs/tools-sessions.mdx +39 -10
  306. package/docs/tools-snapshot.mdx +57 -3
  307. package/docs/tools-state.mdx +24 -1
  308. package/docs/tools-tools-and-run.mdx +55 -24
  309. package/docs/tools-wait-for.mdx +34 -15
  310. package/docs/troubleshooting.mdx +80 -10
  311. package/docs/usage.md +46 -24
  312. package/docs/vs-chrome-devtools-mcp.mdx +41 -0
  313. package/docs/vs-playwright-mcp.mdx +42 -2
  314. package/docs/vs-screenshots.mdx +42 -1
  315. package/docs/what-is-reticle.mdx +1 -1
  316. package/docs/why-reticle.mdx +3 -1
  317. package/package.json +5 -5
  318. package/docs/desktop-apps.md +0 -265
@@ -12,9 +12,9 @@
12
12
  * tokens on that and not on politeness.
13
13
  */
14
14
  import { z } from 'zod';
15
- import { FeedbackKind, FeedbackSource } from '@reticlehq/core';
15
+ import { FeedbackKind, FeedbackSource, FEEDBACK_RATING_MAX, FEEDBACK_RATING_MIN, } from '@reticlehq/core';
16
16
  import { ReticleTool } from './tool-names.js';
17
- import { asString } from './tools-helpers.js';
17
+ import { asNumber, asString } from './tools-helpers.js';
18
18
  import { submitFeedback, feedbackDisabled } from '../telemetry/feedback.js';
19
19
  import { getTelemetry } from '../telemetry/telemetry.js';
20
20
  /** One optional string arg, present only when the agent actually supplied it. */
@@ -22,6 +22,19 @@ function optionalText(args, key) {
22
22
  const value = asString(args[key]);
23
23
  return value !== undefined && value !== '' ? { [key]: value } : {};
24
24
  }
25
+ /**
26
+ * The star rating, present only when the agent actually supplied one, and only when it is on the
27
+ * scale core defines. Bounds-checked HERE as well as in the schema because a number that survives
28
+ * to the wire off-scale is worse than an absent one: it still averages.
29
+ */
30
+ function optionalRating(args) {
31
+ const value = asNumber(args['rating']);
32
+ if (value === undefined || !Number.isInteger(value))
33
+ return {};
34
+ if (value < FEEDBACK_RATING_MIN || value > FEEDBACK_RATING_MAX)
35
+ return {};
36
+ return { rating: value };
37
+ }
25
38
  /** Read the connected session's self-reported facts, or nothing when no app is attached. */
26
39
  function sessionFacts(deps, sessionId) {
27
40
  const driven = deps.realInput !== undefined;
@@ -45,7 +58,7 @@ function sessionFacts(deps, sessionId) {
45
58
  export const FEEDBACK_TOOLS = [
46
59
  {
47
60
  name: ReticleTool.FEEDBACK,
48
- description: "Tell the Reticle maintainers something — a failure, OR a feature you wish existed. Reticle is built FOR agents, so your experience is the one that decides what gets built, and this is the only channel that reaches us. File a `bug` when a tool returned something wrong, a `gap` when Reticle could not observe what you needed, an `ambiguity` when a verdict was not decidable, a `feature_request` when you thought 'I wish Reticle could do X', or an `improvement` when something works but is awkward (too many calls, a confusing shape, a slow path). Do not save these up or judge them as too minor — file it the moment you notice, then carry on with your task. For a failure, write `text` as a root-cause analysis (what you called, expected, got). For a request, write what you want and use `need` for the GOAL behind it, `impact` for what gets better, and `currentApproach` for how you are working around it today — that workaround is usually the most useful thing in the report. Set `model` to the model you are running. Never include app source, secrets, user data, or customer records. Do NOT use this for bugs you found in the app under test — that is a successful verification, which is Reticle working.",
61
+ description: "Tell the Reticle maintainers something — a failure, OR a feature you wish existed. Reticle is built FOR agents, so your experience is the one that decides what gets built, and this is the only channel that reaches us. File a `bug` when a tool returned something wrong, a `gap` when Reticle could not observe what you needed, an `ambiguity` when a verdict was not decidable, a `feature_request` when you thought 'I wish Reticle could do X', an `improvement` when something works but is awkward (too many calls, a confusing shape, a slow path), or an `experience` when something worked notably WELL (carry `rating` 1-5). File that one unprompted, when you actually notice it, and say which call and what it saved you — every other kind here is a complaint, so it is the only evidence for what is worth protecting when we change things. Do not save these up or judge them as too minor — file it the moment you notice, then carry on with your task. For a failure, write `text` as a root-cause analysis (what you called, expected, got). For a request, write what you want and use `need` for the GOAL behind it, `impact` for what gets better, and `currentApproach` for how you are working around it today — that workaround is usually the most useful thing in the report. Set `model` to the model you are running. Never include app source, secrets, user data, or customer records. Do NOT use this for bugs you found in the app under test — that is a successful verification, which is Reticle working.",
49
62
  inputSchema: {
50
63
  kind: z
51
64
  .enum([
@@ -54,8 +67,9 @@ export const FEEDBACK_TOOLS = [
54
67
  FeedbackKind.AMBIGUITY,
55
68
  FeedbackKind.FEATURE_REQUEST,
56
69
  FeedbackKind.IMPROVEMENT,
70
+ FeedbackKind.EXPERIENCE,
57
71
  ])
58
- .describe('bug = a Reticle tool returned a wrong or broken result; gap = Reticle could not observe something you needed at all; ambiguity = the verification ran but you could not tell pass from fail; feature_request = something that does not exist and would have helped; improvement = something that exists but is awkward or inefficient.'),
72
+ .describe('bug = a Reticle tool returned a wrong or broken result; gap = Reticle could not observe something you needed at all; ambiguity = the verification ran but you could not tell pass from fail; feature_request = something that does not exist and would have helped; improvement = something that exists but is awkward or inefficient; experience = something worked notably well and is worth keeping (pair it with `rating`).'),
59
73
  text: z
60
74
  .string()
61
75
  .min(1)
@@ -72,6 +86,13 @@ export const FEEDBACK_TOOLS = [
72
86
  .string()
73
87
  .optional()
74
88
  .describe('How you work around it TODAY. Usually the most useful field in the report: it proves the need is real, and often shows a cheaper fix than the feature being asked for.'),
89
+ rating: z
90
+ .number()
91
+ .int()
92
+ .min(FEEDBACK_RATING_MIN)
93
+ .max(FEEDBACK_RATING_MAX)
94
+ .optional()
95
+ .describe(`How well it worked, ${FEEDBACK_RATING_MIN}-${FEEDBACK_RATING_MAX}, with an \`experience\`. Only when you formed the judgement while working, never because you were asked: an agreeable number and an earned one are indistinguishable once both sit in the same column. The \`text\` is the part we can act on.`),
75
96
  model: z
76
97
  .string()
77
98
  .optional()
@@ -112,6 +133,7 @@ export const FEEDBACK_TOOLS = [
112
133
  ...optionalText(args, 'impact'),
113
134
  ...optionalText(args, 'currentApproach'),
114
135
  ...optionalText(args, 'model'),
136
+ ...optionalRating(args),
115
137
  }, {
116
138
  session: sessionFacts(deps, asString(args['sessionId'])),
117
139
  // The AGENT does not wait for the network. `reticle feedback` (a human, at a terminal,
@@ -154,8 +176,11 @@ export const FEEDBACK_PROMPT = {
154
176
  export const VERIFICATION_TOOLS = new Set([
155
177
  ReticleTool.ACT_AND_WAIT,
156
178
  ReticleTool.ASSERT,
157
- ReticleTool.FLOW_VERIFY,
158
- ReticleTool.VERIFY_CHANGE,
179
+ // Merged: `flow_verify` and `verify_change` are actions on this tool now. Naming the merged tool
180
+ // does NOT make every action a verification — `verificationOf` still requires the result to carry
181
+ // a `verified` or a pass/fail `status`, so `coverage` and `crawl` contribute nothing and cannot
182
+ // inflate the count.
183
+ ReticleTool.VERIFY,
159
184
  ReticleTool.RECONCILE,
160
185
  ]);
161
186
  /**
@@ -7,4 +7,4 @@ export declare const SESSION_BOUND_TOOLS: ReadonlySet<string>;
7
7
  * `sessionId`-bearing tool to be classified into exactly one set (bound XOR exempt).
8
8
  */
9
9
  export declare const SESSION_EXEMPT_TOOLS: ReadonlySet<string>;
10
- export declare function runTool(tool: ToolDef, deps: ToolDeps, args: Record<string, unknown>): Promise<unknown>;
10
+ export declare function runTool<Ext>(tool: ToolDef<Ext>, deps: ToolDeps<Ext>, args: Record<string, unknown>): Promise<unknown>;
@@ -1,11 +1,11 @@
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
9
  import { noteToolServed, reportToolRefused } from '../telemetry/tool-refused.js';
10
10
  import { buildErrorPayload, refusalReasonFor } from './error-recovery.js';
11
11
  import { resultIsError } from '../mcp/mcp-is-error.js';
@@ -17,6 +17,7 @@ import { takeFeedbackPrompt } from './feedback-tools.js';
17
17
  import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
18
18
  import { noteRefsMinted, wrongTabRefusal } from '../session/ref-provenance.js';
19
19
  import { span } from '../trace.js';
20
+ import { deltaForToolResult, impactSnapshot, initImpact, recordImpact, } from '../impact/impact-recorder.js';
20
21
  import { frictionOf, inviteFor } from './feedback-invite.js';
21
22
  /**
22
23
  * The live-session tools whose result MUST carry the
@@ -46,14 +47,21 @@ const REF_MINTING_TOOLS = new Set([
46
47
  ReticleTool.QUERY,
47
48
  ReticleTool.INSPECT,
48
49
  ReticleTool.EXPLORE,
49
- ReticleTool.CRAWL,
50
+ // `crawl` mints refs for the controls it drives and `coverage` for the ones it reports untouched.
51
+ // Both are actions on the merged verify tool now.
52
+ ReticleTool.VERIFY,
50
53
  ]);
51
54
  export const SESSION_BOUND_TOOLS = new Set([
52
55
  ReticleTool.SNAPSHOT,
53
56
  ReticleTool.QUERY,
54
57
  ReticleTool.INSPECT,
55
- ReticleTool.COVERAGE,
56
- ReticleTool.VERIFY_CHANGE,
58
+ // Merged change/flows/affected/coverage/crawl. Its members were SPLIT across bound and exempt —
59
+ // coverage/change/crawl bound, flow_verify exempt because it returns its own suite contract — and
60
+ // a merged tool has to be one or the other. Bound is the right side: `crawl` drives the app
61
+ // itself, so a throttled or event-dropping tab is exactly the condition its verdict depends on,
62
+ // and dropping the health disclosure there to protect a sibling's output shape would trade an
63
+ // honesty signal for a formatting preference.
64
+ ReticleTool.VERIFY,
57
65
  ReticleTool.ACT,
58
66
  ReticleTool.ACT_SEQUENCE,
59
67
  ReticleTool.ACT_AND_WAIT,
@@ -73,7 +81,6 @@ export const SESSION_BOUND_TOOLS = new Set([
73
81
  ReticleTool.STATE,
74
82
  ReticleTool.STORAGE,
75
83
  ReticleTool.EXPLORE,
76
- ReticleTool.CRAWL,
77
84
  ReticleTool.SCROLL_TO,
78
85
  ReticleTool.NAVIGATE,
79
86
  ]);
@@ -89,7 +96,6 @@ export const SESSION_EXEMPT_TOOLS = new Set([
89
96
  ReticleTool.FLOW_SAVE, // sessionId only scopes the write to the app's flow subdir; disk-side
90
97
  ReticleTool.FLOW, // merged list/load/delete — sessionId only scopes the project; all disk-side
91
98
  ReticleTool.FLOW_REPLAY, // returns its own FlowReplayResult contract (+ auto-records a run)
92
- ReticleTool.FLOW_VERIFY, // returns its own SuiteVerdict contract (replays the whole suite)
93
99
  ReticleTool.FLOW_SAVE_RECORDED, // reads the recording buffer, writes disk
94
100
  ReticleTool.FLOW_HEAL, // returns its own FlowHealResult contract
95
101
  ReticleTool.PROJECT, // reads .reticle/project.json
@@ -141,14 +147,19 @@ function reportBugsFound(toolName, result) {
141
147
  if (0 === bugs.length)
142
148
  return;
143
149
  const metrics = getSessionMetrics();
150
+ const route = routeOf(result);
144
151
  for (const bug of bugs) {
145
152
  // `recordBug` answers whether this kind is new to the session, which is the only thing that
146
153
  // separates "another defect" from "the same defect again". Counting instances as defects is how
147
154
  // a published number inflates itself.
148
155
  const first = metrics.recordBug(bug.kind);
156
+ const identity = route !== undefined
157
+ ? { kind: bug.kind, source: bug.source, route }
158
+ : { kind: bug.kind, source: bug.source };
159
+ const fingerprint = fingerprintFinding(identity);
149
160
  void getTelemetry().emit(TelemetryEventKind.BUG_FOUND, {
150
161
  actor: TelemetryActor.AGENT,
151
- bug: { ...bug, repeat: !first },
162
+ bug: { ...bug, repeat: !first, fingerprint },
152
163
  });
153
164
  }
154
165
  }
@@ -239,6 +250,9 @@ export async function runTool(tool, deps, args) {
239
250
  // closure carries this call's own identity, which is also what makes peak-concurrency measurable.
240
251
  // A daemon that has served even one tool call is doing a job for somebody; see daemon-usefulness.
241
252
  noteToolCall();
253
+ // The impact record needs the project's own `.reticle` root, and this is the first place every
254
+ // call knows it. Idempotent: the first root wins for the daemon's lifetime.
255
+ initImpact({ reticleRoot: deps.reticleRoot });
242
256
  // An oversized argument is refused BEFORE the handler deserialises it.
243
257
  //
244
258
  // tool-fuzz failed CI on the invariant that matters most — `every tool answers every hostile call`
@@ -352,6 +366,14 @@ export async function runTool(tool, deps, args) {
352
366
  // throw path would have measured half the wall and called it the whole of it, the same way `isError`
353
367
  // once did. A call that was served clears the retry chain, so the next refusal after it is a first
354
368
  // refusal rather than a retry of something unrelated.
369
+ // The user's own record of what Reticle did for them. Same chokepoint as everything else that
370
+ // counts, for the same reason: a second recording site is a second thing to forget. Separate
371
+ // store from telemetry - this one never leaves the machine.
372
+ recordImpact(deltaForToolResult(raw, Date.now() - startedAt, resultIsError(raw)));
373
+ // ...and the tab being driven is told, so the report is live rather than a thing you reload.
374
+ // Optional-call, not optional-chain-on-the-object: a test double is a partial Session, and a
375
+ // courtesy push must never be the reason a tool call throws.
376
+ session?.pushImpact?.(impactSnapshot);
355
377
  if (resultIsError(raw))
356
378
  reportRefusal(tool.name, raw.error);
357
379
  else {
@@ -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;
@@ -5,20 +5,71 @@
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 function leaseNotConnectedHint(url, port) {
18
- return (`the leased tab loaded ${url} but never dialled this daemon (port ${String(port)}). The usual ` +
19
- `cause is a PORT MISMATCH: the app's SDK connects to the port it was BUILT with, so a daemon on ` +
20
- `a different port never hears from it — check the app's reticle port (\`.reticle.json\`, ` +
21
- `RETICLE_PORT, or the build plugin's \`port\`) matches ${String(port)}, or start the daemon on ` +
22
- `the port the app expects. If the app carries no Reticle SDK at all, run \`reticle init\` in it ` +
23
- `first. The tab stays leased either way — release it with reticle_lease{action:"release"}.`);
19
+ const NUXT = 'nuxt';
20
+ const PORT_DIFFERENTIAL = "The usual cause is a PORT MISMATCH: the app's SDK connects to the port it was BUILT with, so a " +
21
+ "daemon on a different port never hears from it — check the app's reticle port (`.reticle.json`, " +
22
+ "RETICLE_PORT, or the build plugin's `port`), or start the daemon on the port the app expects.";
23
+ /**
24
+ * The causes that actually occur on a wired app, in the order they occur. Mirrors the no-session
25
+ * diagnosis deliberately: two surfaces answering the same question must not rank it differently.
26
+ */
27
+ const REAL_CAUSES = 'Causes that produce exactly this and are NOT a port problem: (a) the SDK is in the bundle but ' +
28
+ '`connect()` is never reached — a dev-mode guard or a missing runtime-config value returning ' +
29
+ 'early, which emits nothing at all; (b) the dev server was started BEFORE the Reticle plugin was ' +
30
+ 'added, so it is not in the bundle — restart it, HMR is not enough; (c) a peer dependency is ' +
31
+ 'missing (typically `@reticlehq/react` in a non-React app) so the dynamic import fails silently; ' +
32
+ '(d) the page is not on localhost, where the SDK needs BOTH `allowNonLocalhost: true` and a ' +
33
+ 'pairing token — the flag alone is not sufficient.';
34
+ const NUXT_FIRST = 'This project is Nuxt: the most likely cause by a distance is a dev server that predates the ' +
35
+ 'Reticle plugin, because Nuxt does NOT register a newly added plugin on HMR. Restart `nuxt dev` ' +
36
+ 'and re-acquire before investigating anything else.';
37
+ const RELEASE = 'The tab stays leased either way — release it with reticle_lease{action:"release"}.';
38
+ function markerClause(sdkMarker) {
39
+ if (sdkMarker === undefined)
40
+ return '';
41
+ return sdkMarker
42
+ ? ' A Reticle SDK marker WAS found in the page that loaded, so the app does ship the SDK — the ' +
43
+ 'question is only why it did not reach this daemon.'
44
+ : ' No Reticle SDK marker was found in the page that loaded. That is a check of the SERVED ' +
45
+ 'document, so a lazily-imported SDK can still be missed — but combined with the rest it ' +
46
+ 'points at a bundle that carries no Reticle at all, i.e. wiring that never took effect.';
47
+ }
48
+ export function leaseNotConnectedHint(url, port, evidence = {}) {
49
+ const opening = `the leased tab loaded ${url} but never dialled this daemon (port ${String(port)}).`;
50
+ const marker = markerClause(evidence.sdkMarker);
51
+ // 1. A recorded refusal. The only cause with proof behind it, so nothing outranks it.
52
+ if (evidence.refusal !== undefined) {
53
+ return `${opening} A dial DID arrive and was refused: ${evidence.refusal}${marker} ${RELEASE}`;
54
+ }
55
+ // 2. Nuxt, whose most likely cause differs from every other framework's and which `init` warns
56
+ // about at install time — while this hint never mentioned it.
57
+ const nuxt = evidence.framework?.toLowerCase() === NUXT ? ` ${NUXT_FIRST}` : '';
58
+ // 3. Proven wiring. Blaming the port here is the self-contradiction this rewrite exists to end.
59
+ if (true === evidence.previouslyConnected) {
60
+ return (`${opening} An app for this project HAS connected on this port before, so the port and the ` +
61
+ `wiring are both correct — what is failing is the SDK reaching initialise on the page.${nuxt}` +
62
+ `${marker} ${REAL_CAUSES} ${RELEASE}`);
63
+ }
64
+ // 4. Wired, but never seen connect. Rank the real causes; keep the port differential last, where
65
+ // the evidence for it actually sits.
66
+ if (true === evidence.initialized) {
67
+ return `${opening}${nuxt}${marker} ${REAL_CAUSES} If none of those, it may be dialling a different daemon than this one: check the app's reticle port matches ${String(port)}. ${RELEASE}`;
68
+ }
69
+ // 5. Nothing known. The differential, plus the possibility this app carries no SDK at all —
70
+ // except when the marker check already ruled that out, which is the whole reason for the bit.
71
+ const noSdk = true === evidence.sdkMarker
72
+ ? ''
73
+ : ' If the app carries no Reticle SDK at all, run `reticle init` in it first.';
74
+ return `${opening}${nuxt}${marker} ${PORT_DIFFERENTIAL}${noSdk} ${REAL_CAUSES} ${RELEASE}`;
24
75
  }
@@ -71,4 +71,15 @@ export declare function acquireLeasedSession(pool: {
71
71
  release: () => Promise<void>;
72
72
  }>;
73
73
  export declare function waitForLeasedSession(isConnected: () => boolean, sleeper?: (ms: number) => Promise<void>, attempts?: number, pollMs?: number): Promise<boolean>;
74
+ /**
75
+ * Named on its own because `reticle drive <url>` runs it too.
76
+ *
77
+ * When a daemon already owns the bridge port, `drive` asks that daemon for a browser instead of
78
+ * competing for the port (see cli/drive-attach.ts), and the browser it should get is exactly the one
79
+ * an agent gets: same pool, same isolation, same reporting. Sharing the ToolDef rather than the
80
+ * handler keeps that literal — the drive route dispatches through `runTool`, so it is counted like
81
+ * any other call instead of being a second, invisible dispatch path.
82
+ */
83
+ export declare const LEASE_ACQUIRE_TOOL: ToolDef;
84
+ export declare const LEASE_RELEASE_TOOL: ToolDef;
74
85
  export declare const LEASE_TOOLS: ToolDef[];
@@ -10,9 +10,32 @@
10
10
  */
11
11
  import { z } from 'zod';
12
12
  import { leaseNotConnectedHint } from './lease-hint.js';
13
+ import { readProjectFramework, readProjectId } from '../cli/cli-port.js';
14
+ import { hasConnectedBefore } from '../session/connection-memory.js';
15
+ import { reticleStateHome } from '../daemon/daemon.js';
13
16
  import { RETICLE_URL_PARAM, RETICLE_DEFAULT_PORT } from '@reticlehq/core';
14
17
  import { ReticleTool } from './tool-names.js';
15
18
  import { asString } from './tools-helpers.js';
19
+ import { chromiumHint } from '../cli/chromium-hint.js';
20
+ /**
21
+ * Everything the daemon already knows about why a leased tab might not have dialled in.
22
+ *
23
+ * Gathered here rather than guessed in the hint, because the daemon HAD this evidence all along and
24
+ * printed a static differential over the top of it — telling one agent in `reticle_sessions` that
25
+ * the wiring was proven correct and telling it seconds later that the port was probably wrong.
26
+ */
27
+ function leaseEvidence(deps, port) {
28
+ const cwd = process.cwd();
29
+ const projectId = readProjectId(cwd);
30
+ const refusal = deps.sessions.lastClosure()?.reason;
31
+ const framework = readProjectFramework(cwd);
32
+ return {
33
+ ...(refusal === undefined ? {} : { refusal }),
34
+ ...(framework === undefined ? {} : { framework }),
35
+ previouslyConnected: hasConnectedBefore(reticleStateHome(), port, projectId),
36
+ initialized: projectId !== undefined,
37
+ };
38
+ }
16
39
  const POOL_UNAVAILABLE = 'browser pool unavailable — the lease tools need the daemon-managed pool (start Reticle via `reticle mcp`).';
17
40
  /**
18
41
  * Append Reticle identity params (__reticle_session, optional __reticle_project) to a URL so the app's own SDK
@@ -129,102 +152,124 @@ export async function waitForLeasedSession(isConnected, sleeper = sleep, attempt
129
152
  }
130
153
  return isConnected();
131
154
  }
132
- export const LEASE_TOOLS = [
133
- {
134
- name: ReticleTool.LEASE_ACQUIRE,
135
- description: 'Lease a fresh isolated headless browser context from the shared pool and navigate it to the app URL (the app must already be running and embed @reticlehq/core). Returns the sessionId the leased tab registers — pass it to other tools. The pool keeps all leases in ONE browser and caps concurrency; if at capacity this waits for a free slot. Release with reticle_lease{action:"release"} when the flow is done.',
136
- inputSchema: {
137
- url: z
138
- .string()
139
- .describe('URL of the already-running app to drive (e.g. http://localhost:3000/dashboard).'),
140
- projectId: z
141
- .string()
142
- .optional()
143
- .describe('Stable project id to stamp on the leased tab so the agent can scope to it.'),
144
- },
145
- outputSchema: {
146
- sessionId: z.string(),
147
- url: z.string(),
148
- ready: z
149
- .boolean()
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.'),
154
- leased: z.number().describe('How many contexts are currently leased from the pool.'),
155
- queued: z.number().describe('How many acquires are waiting for a free slot.'),
156
- hint: z.string().optional(),
157
- },
158
- handler: async (deps, args) => {
159
- const pool = deps.pool;
160
- if (pool === undefined)
161
- throw new Error(POOL_UNAVAILABLE);
162
- const url = asString(args['url']);
163
- if (url === undefined || 0 === url.length)
164
- throw new Error('reticle_lease{action:"acquire"} requires a url');
165
- const projectId = asString(args['projectId']);
166
- const sessionId = newLeaseId();
167
- const navUrl = appendReticleParams(url, sessionId, projectId);
168
- let lease;
169
- try {
170
- lease = await pool.acquire(navUrl, { sessionId });
171
- }
172
- catch (err) {
173
- // A raw page.goto failure is noisy and leaks the internal URL params — surface a clean,
174
- // actionable message instead.
175
- throw new Error(`could not open ${url} — is the app running there? (${cleanNavError(err)})`);
176
- }
177
- // Wait for the leased tab's SDK to connect so the returned sessionId is usable right away.
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);
191
- return {
192
- sessionId: registeredId ?? lease.sessionId,
193
- url,
194
- ready,
195
- expiresInMs: pool.leaseTtlMs(),
196
- leased: pool.activeCount(),
197
- queued: pool.queuedCount(),
198
- ...(ready
199
- ? {}
200
- : {
201
- hint: leaseNotConnectedHint(url, deps.bridgePort ?? RETICLE_DEFAULT_PORT),
202
- }),
203
- };
204
- },
155
+ /**
156
+ * Named on its own because `reticle drive <url>` runs it too.
157
+ *
158
+ * When a daemon already owns the bridge port, `drive` asks that daemon for a browser instead of
159
+ * competing for the port (see cli/drive-attach.ts), and the browser it should get is exactly the one
160
+ * an agent gets: same pool, same isolation, same reporting. Sharing the ToolDef rather than the
161
+ * handler keeps that literal — the drive route dispatches through `runTool`, so it is counted like
162
+ * any other call instead of being a second, invisible dispatch path.
163
+ */
164
+ export const LEASE_ACQUIRE_TOOL = {
165
+ name: ReticleTool.LEASE_ACQUIRE,
166
+ description: 'Lease a fresh isolated headless browser context from the shared pool and navigate it to the app URL (the app must already be running and embed @reticlehq/core). Returns the sessionId the leased tab registers — pass it to other tools. The pool keeps all leases in ONE browser and caps concurrency; if at capacity this waits for a free slot. Release with reticle_lease{action:"release"} when the flow is done.',
167
+ inputSchema: {
168
+ url: z
169
+ .string()
170
+ .describe('URL of the already-running app to drive (e.g. http://localhost:3000/dashboard).'),
171
+ projectId: z
172
+ .string()
173
+ .optional()
174
+ .describe('Stable project id to stamp on the leased tab so the agent can scope to it.'),
205
175
  },
206
- {
207
- name: ReticleTool.LEASE_RELEASE,
208
- description: 'Release a leased browser context by sessionId, closing it and freeing the pool slot for a queued acquire. Call this when a flow finishes so the pool stays within its concurrency cap.',
209
- inputSchema: {
210
- sessionId: z
211
- .string()
212
- .describe('The leased sessionId returned by reticle_lease{action:"acquire"}.'),
213
- },
214
- outputSchema: {
215
- released: z.boolean(),
216
- leased: z.number(),
217
- },
218
- handler: async (deps, args) => {
219
- const pool = deps.pool;
220
- if (pool === undefined)
221
- throw new Error(POOL_UNAVAILABLE);
222
- const sessionId = asString(args['sessionId']);
223
- if (sessionId === undefined || 0 === sessionId.length) {
224
- throw new Error('reticle_lease{action:"release"} requires a sessionId');
176
+ outputSchema: {
177
+ sessionId: z.string(),
178
+ url: z.string(),
179
+ ready: z
180
+ .boolean()
181
+ .describe('Whether the leased tab connected — false ⇒ the app may not embed @reticlehq/core.'),
182
+ expiresInMs: z
183
+ .number()
184
+ .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.'),
185
+ leased: z.number().describe('How many contexts are currently leased from the pool.'),
186
+ queued: z.number().describe('How many acquires are waiting for a free slot.'),
187
+ hint: z.string().optional(),
188
+ },
189
+ handler: async (deps, args) => {
190
+ const pool = deps.pool;
191
+ if (pool === undefined)
192
+ throw new Error(POOL_UNAVAILABLE);
193
+ const url = asString(args['url']);
194
+ if (url === undefined || 0 === url.length)
195
+ throw new Error('reticle_lease{action:"acquire"} requires a url');
196
+ // Preflight the browser before spending the round trip. Without it a missing Playwright Chromium
197
+ // only surfaces inside pool.acquire, where the launch failure is caught and reported as
198
+ // "could not open <url> — is the app running?" — sending the caller to debug an app that is
199
+ // fine. Say the real thing at the first refusal instead. The phrasing carries "Chromium is not
200
+ // installed" so error-recovery routes it to the NO_POOL fix (install + drive a human tab).
201
+ if (deps.browserProbe !== undefined) {
202
+ const probe = await deps.browserProbe();
203
+ if (!probe.exists) {
204
+ throw new Error(`Chromium is not installed for Playwright — ${chromiumHint(probe)}`);
225
205
  }
226
- await pool.release(sessionId);
227
- return { released: true, leased: pool.activeCount() };
228
- },
206
+ }
207
+ const projectId = asString(args['projectId']);
208
+ const sessionId = newLeaseId();
209
+ const navUrl = appendReticleParams(url, sessionId, projectId);
210
+ let lease;
211
+ try {
212
+ lease = await pool.acquire(navUrl, { sessionId });
213
+ }
214
+ catch (err) {
215
+ // A raw page.goto failure is noisy and leaks the internal URL params — surface a clean,
216
+ // actionable message instead.
217
+ throw new Error(`could not open ${url} — is the app running there? (${cleanNavError(err)})`);
218
+ }
219
+ // Wait for the leased tab's SDK to connect so the returned sessionId is usable right away.
220
+ // Resolved rather than assumed: an app that names its own session registers under that name,
221
+ // and the id we hand back has to be the one the agent can actually drive.
222
+ let registeredId;
223
+ const ready = await waitForLeasedSession(() => {
224
+ registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
225
+ return registeredId !== undefined;
226
+ });
227
+ // Tell the pool the other name this lease answers to. Every later touch and release arrives
228
+ // under the id we are about to hand back, and the pool is keyed by the id it navigated with;
229
+ // without this the touches miss, the lease ages out despite continuous activity, and the
230
+ // reaper closes the context mid-flow. See BrowserPool.alias and #157.
231
+ if (registeredId !== undefined)
232
+ pool.alias(registeredId, lease.sessionId);
233
+ return {
234
+ sessionId: registeredId ?? lease.sessionId,
235
+ url,
236
+ ready,
237
+ expiresInMs: pool.leaseTtlMs(),
238
+ leased: pool.activeCount(),
239
+ queued: pool.queuedCount(),
240
+ ...(ready
241
+ ? {}
242
+ : (() => {
243
+ const bridgePort = deps.bridgePort ?? RETICLE_DEFAULT_PORT;
244
+ return {
245
+ hint: leaseNotConnectedHint(url, bridgePort, leaseEvidence(deps, bridgePort)),
246
+ };
247
+ })()),
248
+ };
249
+ },
250
+ };
251
+ export const LEASE_RELEASE_TOOL = {
252
+ name: ReticleTool.LEASE_RELEASE,
253
+ description: 'Release a leased browser context by sessionId, closing it and freeing the pool slot for a queued acquire. Call this when a flow finishes so the pool stays within its concurrency cap.',
254
+ inputSchema: {
255
+ sessionId: z
256
+ .string()
257
+ .describe('The leased sessionId returned by reticle_lease{action:"acquire"}.'),
258
+ },
259
+ outputSchema: {
260
+ released: z.boolean(),
261
+ leased: z.number(),
262
+ },
263
+ handler: async (deps, args) => {
264
+ const pool = deps.pool;
265
+ if (pool === undefined)
266
+ throw new Error(POOL_UNAVAILABLE);
267
+ const sessionId = asString(args['sessionId']);
268
+ if (sessionId === undefined || 0 === sessionId.length) {
269
+ throw new Error('reticle_lease{action:"release"} requires a sessionId');
270
+ }
271
+ await pool.release(sessionId);
272
+ return { released: true, leased: pool.activeCount() };
229
273
  },
230
- ];
274
+ };
275
+ export const LEASE_TOOLS = [LEASE_ACQUIRE_TOOL, LEASE_RELEASE_TOOL];