@reticlehq/server 3.8.2 → 3.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 (224) hide show
  1. package/LICENSE +201 -104
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/bin/launch.js +13 -0
  5. package/bin/package.json +3 -0
  6. package/bin/reticle.js +66 -8
  7. package/dist/chromium-launch-options.d.ts +25 -0
  8. package/dist/chromium-launch-options.js +21 -1
  9. package/dist/command/cli/adhoc-verdict.js +34 -7
  10. package/dist/command/cli/answers/gate-exit.d.ts +6 -2
  11. package/dist/command/cli/answers/gate-exit.js +12 -2
  12. package/dist/command/cli/ci-hint.d.ts +44 -0
  13. package/dist/command/cli/ci-hint.js +72 -0
  14. package/dist/command/cli/cli-doctor.d.ts +6 -1
  15. package/dist/command/cli/cli-doctor.js +45 -4
  16. package/dist/command/cli/cli-flow-commands.d.ts +7 -1
  17. package/dist/command/cli/cli-flow-commands.js +39 -17
  18. package/dist/command/cli/cli-kill.d.ts +21 -0
  19. package/dist/command/cli/cli-kill.js +46 -3
  20. package/dist/command/cli/cli-parse.d.ts +5 -2
  21. package/dist/command/cli/cli-parse.js +10 -5
  22. package/dist/command/cli/cli-usage.js +3 -2
  23. package/dist/command/cli/cli-verify.d.ts +6 -1
  24. package/dist/command/cli/cli-verify.js +32 -13
  25. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
  26. package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
  27. package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
  28. package/dist/command/cli/doctor/doctor-fix.js +89 -0
  29. package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
  30. package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
  31. package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
  32. package/dist/command/cli/doctor/doctor-rows.js +4 -0
  33. package/dist/command/cli/doctor-repairs.d.ts +64 -0
  34. package/dist/command/cli/doctor-repairs.js +214 -0
  35. package/dist/command/cli/drive/drive-command.js +3 -7
  36. package/dist/command/cli/first-use-checks.d.ts +41 -0
  37. package/dist/command/cli/first-use-checks.js +127 -0
  38. package/dist/command/cli/first-verdict-node.d.ts +3 -0
  39. package/dist/command/cli/first-verdict-node.js +189 -0
  40. package/dist/command/cli/first-verdict.d.ts +126 -0
  41. package/dist/command/cli/first-verdict.js +267 -0
  42. package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
  43. package/dist/command/cli/launch/sdk-version-match.js +3 -2
  44. package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
  45. package/dist/command/cli/mcp-command.js +13 -4
  46. package/dist/command/cli/ports/port-holder.d.ts +2 -0
  47. package/dist/command/cli/ports/port-holder.js +11 -3
  48. package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
  49. package/dist/command/cli/setup-mcp-cli.js +98 -23
  50. package/dist/command/cli/status/status-lines.d.ts +1 -0
  51. package/dist/command/cli/status/status-lines.js +50 -17
  52. package/dist/command/cli/status-fields.js +17 -3
  53. package/dist/command/cli/try-command.d.ts +7 -0
  54. package/dist/command/cli/try-command.js +11 -3
  55. package/dist/command/cli.js +70 -33
  56. package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
  57. package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
  58. package/dist/command/daemon/daemon-argv.d.ts +9 -0
  59. package/dist/command/daemon/daemon-argv.js +11 -0
  60. package/dist/command/daemon/daemon.d.ts +10 -3
  61. package/dist/command/daemon/daemon.js +17 -6
  62. package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
  63. package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
  64. package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
  65. package/dist/command/daemon/lifetime/death-cause.js +130 -0
  66. package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
  67. package/dist/command/daemon/lifetime/heartbeat.js +4 -13
  68. package/dist/command/daemon/port-state.d.ts +14 -0
  69. package/dist/command/daemon/port-state.js +31 -0
  70. package/dist/command/setup/agent-configs.d.ts +2 -0
  71. package/dist/command/setup/agent-configs.js +42 -15
  72. package/dist/command/setup/agent-next-steps.d.ts +77 -0
  73. package/dist/command/setup/agent-next-steps.js +284 -0
  74. package/dist/command/setup/agent-writer.js +11 -6
  75. package/dist/command/setup/init/init-host.js +2 -0
  76. package/dist/command/setup/init/init-runtime.d.ts +6 -1
  77. package/dist/command/setup/init/init-runtime.js +57 -21
  78. package/dist/command/setup/mcp-launcher.d.ts +3 -0
  79. package/dist/command/setup/mcp-launcher.js +11 -0
  80. package/dist/command/setup/setup-command.js +30 -12
  81. package/dist/command/setup/setup-mcp.d.ts +8 -0
  82. package/dist/command/setup/setup-mcp.js +47 -20
  83. package/dist/command/setup/start-dev-server.d.ts +8 -0
  84. package/dist/command/setup/start-dev-server.js +43 -0
  85. package/dist/command/version/identity/server-version.d.ts +2 -0
  86. package/dist/command/version/identity/server-version.js +2 -0
  87. package/dist/command/version/version-skew.d.ts +7 -0
  88. package/dist/command/version/version-skew.js +13 -2
  89. package/dist/features/license/license.d.ts +11 -71
  90. package/dist/features/license/license.js +43 -136
  91. package/dist/features/visual/desktop-capture.d.ts +33 -0
  92. package/dist/features/visual/desktop-capture.js +94 -0
  93. package/dist/features/visual/step-shots.js +139 -35
  94. package/dist/features/visual/visual-tools.d.ts +0 -18
  95. package/dist/features/visual/visual-tools.js +2 -81
  96. package/dist/import-graph.js +5 -6
  97. package/dist/index.d.ts +3 -1
  98. package/dist/index.js +14 -5
  99. package/dist/judgement/runs/run-ledger.d.ts +7 -5
  100. package/dist/judgement/runs/run-ledger.js +98 -69
  101. package/dist/judgement/runs/verification-sync.d.ts +6 -3
  102. package/dist/judgement/runs/verification-sync.js +21 -10
  103. package/dist/language/flows/flow-store-for-session.js +7 -5
  104. package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
  105. package/dist/language/flows/recording/tape/recordings.js +41 -44
  106. package/dist/language/flows/replay.d.ts +17 -7
  107. package/dist/language/flows/replay.js +95 -44
  108. package/dist/launch-chromium.d.ts +1 -0
  109. package/dist/launch-chromium.js +49 -12
  110. package/dist/memory/cloud/sync-screenshots.js +6 -1
  111. package/dist/portal/bridge/bridge.d.ts +13 -4
  112. package/dist/portal/bridge/bridge.js +10 -9
  113. package/dist/portal/input/network-mock.js +3 -2
  114. package/dist/portal/input/real-input.d.ts +15 -0
  115. package/dist/portal/input/real-input.js +59 -3
  116. package/dist/portal/input/viewport-tools.js +6 -3
  117. package/dist/portal/pool/browser-pool.d.ts +5 -1
  118. package/dist/portal/pool/browser-pool.js +32 -38
  119. package/dist/portal/pool/context-permissions.d.ts +20 -17
  120. package/dist/portal/pool/context-permissions.js +63 -1
  121. package/dist/portal/pool/csp-widen.d.ts +62 -0
  122. package/dist/portal/pool/csp-widen.js +132 -0
  123. package/dist/portal/pool/lease-page-error.d.ts +29 -0
  124. package/dist/portal/pool/lease-page-error.js +18 -0
  125. package/dist/portal/pool/page-error-journal.d.ts +57 -0
  126. package/dist/portal/pool/page-error-journal.js +181 -0
  127. package/dist/portal/pool/playwright-launcher.js +21 -8
  128. package/dist/portal/pool/pool-contract.d.ts +22 -0
  129. package/dist/portal/pool/pool-lease.d.ts +35 -2
  130. package/dist/portal/pool/pool-lease.js +23 -0
  131. package/dist/portal/pool/zero-install.js +69 -1
  132. package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
  133. package/dist/portal/session/dev-server/dev-command.js +56 -8
  134. package/dist/portal/session/no-session-diagnosis.js +10 -3
  135. package/dist/portal/session/no-session-next-action.d.ts +12 -0
  136. package/dist/portal/session/no-session-next-action.js +44 -4
  137. package/dist/portal/session/no-session-watch.js +3 -1
  138. package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
  139. package/dist/portal/session/page-commands/undeclared-command.js +16 -7
  140. package/dist/portal/session/recorded-gaps.d.ts +1 -1
  141. package/dist/portal/session/recorded-gaps.js +5 -2
  142. package/dist/portal/session/session-manager.js +40 -21
  143. package/dist/portal/session/session.js +4 -3
  144. package/dist/portal/session/zero-install-first-call.d.ts +20 -1
  145. package/dist/portal/session/zero-install-first-call.js +34 -2
  146. package/dist/start-wiring.d.ts +29 -2
  147. package/dist/start-wiring.js +73 -9
  148. package/dist/surface/mcp/mcp-proxy.js +10 -2
  149. package/dist/surface/mcp/mcp.js +5 -3
  150. package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
  151. package/dist/surface/mcp/peer/agents-connected.js +58 -0
  152. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
  153. package/dist/surface/tools/act/act-preflight.js +37 -17
  154. package/dist/surface/tools/act/act-target.js +3 -1
  155. package/dist/surface/tools/act/after-match.d.ts +7 -0
  156. package/dist/surface/tools/act/after-match.js +33 -0
  157. package/dist/surface/tools/act/resolve-target.d.ts +2 -0
  158. package/dist/surface/tools/act/resolve-target.js +35 -8
  159. package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
  160. package/dist/surface/tools/act/sequence-grade.js +8 -1
  161. package/dist/surface/tools/act-sequence-tool.js +3 -0
  162. package/dist/surface/tools/act-tools.js +10 -10
  163. package/dist/surface/tools/assert/assert-verdict.js +28 -1
  164. package/dist/surface/tools/browser-tools.js +75 -7
  165. package/dist/surface/tools/dynamic-tools.js +2 -1
  166. package/dist/surface/tools/empty-session-list.d.ts +2 -0
  167. package/dist/surface/tools/empty-session-list.js +4 -2
  168. package/dist/surface/tools/error-recovery.js +43 -22
  169. package/dist/surface/tools/invoke-tool.js +27 -20
  170. package/dist/surface/tools/lease-hint.d.ts +7 -0
  171. package/dist/surface/tools/lease-hint.js +10 -3
  172. package/dist/surface/tools/lease-readiness.d.ts +21 -0
  173. package/dist/surface/tools/lease-readiness.js +47 -0
  174. package/dist/surface/tools/lease-seed.d.ts +2 -0
  175. package/dist/surface/tools/lease-seed.js +2 -1
  176. package/dist/surface/tools/lease-tools.js +43 -15
  177. package/dist/surface/tools/repeat-hint.d.ts +40 -0
  178. package/dist/surface/tools/repeat-hint.js +171 -0
  179. package/dist/surface/tools/run-report.d.ts +2 -2
  180. package/dist/surface/tools/run-report.js +27 -15
  181. package/dist/surface/tools/tool-kit.d.ts +12 -0
  182. package/dist/surface/tools/tool-kit.js +7 -0
  183. package/dist/surface/tools/tools.js +26 -13
  184. package/dist/telemetry/argument-shape.js +16 -7
  185. package/dist/telemetry/cli-telemetry.d.ts +10 -1
  186. package/dist/telemetry/cli-telemetry.js +15 -0
  187. package/dist/telemetry/init-telemetry.d.ts +2 -1
  188. package/dist/telemetry/init-telemetry.js +47 -2
  189. package/dist/telemetry/install-trace.js +23 -4
  190. package/dist/telemetry/onboarding-funnel.d.ts +19 -2
  191. package/dist/telemetry/onboarding-funnel.js +25 -3
  192. package/dist/telemetry/proxy-telemetry.d.ts +2 -1
  193. package/dist/telemetry/proxy-telemetry.js +2 -2
  194. package/dist/telemetry/session-metrics.d.ts +1 -1
  195. package/dist/telemetry/session-metrics.js +67 -27
  196. package/dist/telemetry/telemetry.d.ts +3 -1
  197. package/dist/telemetry/telemetry.js +60 -28
  198. package/dist/telemetry/tool-refused.d.ts +2 -2
  199. package/dist/telemetry/tool-refused.js +9 -2
  200. package/docs/architecture.md +3 -6
  201. package/docs/cli/doctor.mdx +41 -2
  202. package/docs/cli/help.mdx +4 -2
  203. package/docs/cli/license.mdx +4 -5
  204. package/docs/cli/setup.mdx +13 -2
  205. package/docs/cli/status.mdx +4 -0
  206. package/docs/cli.mdx +1 -1
  207. package/docs/deploy-checks.md +25 -0
  208. package/docs/desktop.mdx +1 -1
  209. package/docs/enterprise.md +15 -20
  210. package/docs/environment.mdx +1 -0
  211. package/docs/first-drive.md +1 -1
  212. package/docs/install-manual.mdx +16 -1
  213. package/docs/packages/engine.mdx +1 -1
  214. package/docs/packages/server.mdx +1 -1
  215. package/docs/packages/tauri.mdx +1 -1
  216. package/docs/packages/test.mdx +1 -1
  217. package/docs/packages.mdx +1 -1
  218. package/docs/platform-integration.md +1 -1
  219. package/docs/skill.md +1 -1
  220. package/docs/telemetry.md +4 -2
  221. package/package.json +13 -9
  222. package/LICENSE-ENTERPRISE +0 -37
  223. package/dist/features/ee/audit-log.d.ts +0 -14
  224. package/dist/features/ee/audit-log.js +0 -11
@@ -12,7 +12,7 @@
12
12
  import * as production from '../../portal/production/production-activation.js';
13
13
  import { z } from 'zod';
14
14
  import { leaseNotConnectedHint, leaseReaderFailedHint, leaseStillLoadingHint, } from './lease-hint.js';
15
- import { connectOrInject, moveReusedLease, notReadyReason, probeLeaseAlive, } from './lease-readiness.js';
15
+ import { LEASE_READY_TIMEOUT_MS, awaitLeaseAnswering, connectOrInject, connectWaitUntil, moveReusedLease, notReadyReason, probeLeaseAlive, } from './lease-readiness.js';
16
16
  import { probeSdkMarker } from './gaps/sdk-marker-probe.js';
17
17
  import { observeWebDocument } from '../../portal/session/dev-server/served-document.js';
18
18
  import { diagnoseObservedWebCsp } from '@reticlehq/init';
@@ -25,7 +25,8 @@ import { LeaseNotReadyReason, RETICLE_URL_PARAM, RETICLE_DEFAULT_PORT, SeedStora
25
25
  import { HudVisibility, ReticleTool, SESSION_HEALTH } from '@reticlehq/core';
26
26
  import { HOST_STUB_NOTE } from './tool-kit.js';
27
27
  import { PoolAtCapacityError } from '../../portal/pool/browser-pool.js';
28
- import { asString } from '@reticlehq/core';
28
+ import { asNumber, asString } from '@reticlehq/core';
29
+ import { timeoutMsSchema } from './args/numeric-bounds.js';
29
30
  import { chromiumPreflightRefusal } from '../../command/cli/doctor/browser/chromium-hint.js';
30
31
  import { LEASE_PERMISSIONS_ARG, hintOf, notificationReadBack, parseLeasePermissions, permissionRefusal, refuseRegrant, } from './lease-permissions.js';
31
32
  import { LEASE_EMULATE_ARG, leaseEmulationOf } from '../../portal/input/viewport-tools.js';
@@ -90,7 +91,7 @@ function alreadyHeldHint(sessionId, origin) {
90
91
  return (`${ALREADY_HELD_LEASE} (${origin}): ${sessionId} — reused it rather than opening a second ` +
91
92
  'tab, which would make every later call require sessionId. It is still signed in as whoever ' +
92
93
  'used it last (cookies and storage come with it): release it first for a fresh context or ' +
93
- 'another identity.');
94
+ 'another identity, or pass fresh: true to open a separate tab and keep this one.');
94
95
  }
95
96
  /**
96
97
  * Append Reticle identity params (__reticle_session, optional __reticle_project) to a URL so the app's own SDK
@@ -259,6 +260,10 @@ export const LEASE_ACQUIRE_TOOL = {
259
260
  .optional()
260
261
  .describe('Stable project id to stamp on the leased tab so the agent can scope to it.'),
261
262
  seedStorage: SeedStorageSchema.optional().describe('Initial storage state (local, session and cookies) to seed before the first navigation (e.g. `seedStorage: { local: { token: "..." } }` to start already authenticated).'),
263
+ fresh: z
264
+ .boolean()
265
+ .optional()
266
+ .describe('Open a separate tab even when this connection already holds a lease on the origin, and keep that one. For parallel work that shares one MCP connection (sub-agents). Pass the returned sessionId to every later call.'),
262
267
  initScript: z
263
268
  .string()
264
269
  .optional()
@@ -278,6 +283,9 @@ export const LEASE_ACQUIRE_TOOL = {
278
283
  permissions: LEASE_PERMISSIONS_ARG,
279
284
  emulate: LEASE_EMULATE_ARG,
280
285
  production: production.PRODUCTION_LEASE_ARG,
286
+ timeout_ms: timeoutMsSchema
287
+ .optional()
288
+ .describe(`How long to wait for the leased tab to connect and answer before answering ready:false with notReadyReason. Default ${String(LEASE_READY_TIMEOUT_MS)}. 0 looks once without waiting.`),
281
289
  root: z
282
290
  .string()
283
291
  .optional()
@@ -288,7 +296,7 @@ export const LEASE_ACQUIRE_TOOL = {
288
296
  url: z.string(),
289
297
  ready: z
290
298
  .boolean()
291
- .describe('Whether the leased tab is connected AND answering. It is NOT a claim that the app has hydrated, so an act can miss: make the first act reticle_act_and_wait with an `until`, or reticle_assert { action: "wait" }. Empty reads may mean not hydrated. False ⇒ read notReadyReason.'),
299
+ .describe('Whether the leased tab is connected AND answered a probe under sessionId, so the next call finds it. acquire waits for this, up to timeout_ms. It is NOT a claim that the app has hydrated, so an act can miss: make the first act reticle_act_and_wait with an `until`, or reticle_assert { action: "wait" }. Empty reads may mean not hydrated. False ⇒ read notReadyReason.'),
292
300
  zeroInstall: z
293
301
  .boolean()
294
302
  .optional()
@@ -297,6 +305,10 @@ export const LEASE_ACQUIRE_TOOL = {
297
305
  .string()
298
306
  .optional()
299
307
  .describe('Present when this leased tab runs an init script stubbing the bindings a desktop shell injects. Verdicts on this lease are about the app with that stub, not about the real shell.'),
308
+ cspWidenedFor: z
309
+ .string()
310
+ .optional()
311
+ .describe("Present when the lease added this origin (the daemon's WebSocket) to the page CSP connect-src, so the zero-install reader can connect. The rest of the page CSP applies as the server sent it."),
300
312
  notReadyReason: z
301
313
  .enum([
302
314
  LeaseNotReadyReason.SDK_NEVER_DIALLED,
@@ -408,14 +420,17 @@ export const LEASE_ACQUIRE_TOOL = {
408
420
  // an init script has to exist before the page's own scripts run, which a loaded page cannot
409
421
  // do, and the resolve below then finds nothing and mints a fresh context with the stub. A
410
422
  // lease of the other kind is left alone, exactly as it is when a window was asked for.
411
- const heldForStub = origin === undefined ? undefined : pool.leaseIdOnOrigin(origin, deps.attachId);
423
+ // `fresh` asks for a separate tab: the held one is neither reused nor released (#1932).
424
+ const fresh = true === args['fresh'];
425
+ const heldOn = (o) => fresh || o === undefined ? undefined : pool.leaseIdOnOrigin(o, deps.attachId);
426
+ const heldForStub = heldOn(origin);
412
427
  if (initScript !== undefined &&
413
428
  heldForStub !== undefined &&
414
429
  pool.isHeaded(heldForStub) === headed &&
415
430
  pool.hostStubScript?.(heldForStub) !== initScript) {
416
431
  await pool.release(heldForStub);
417
432
  }
418
- const held = origin === undefined ? undefined : pool.leaseIdOnOrigin(origin, deps.attachId);
433
+ const held = heldOn(origin);
419
434
  const existing = held !== undefined && pool.isHeaded(held) === headed ? held : undefined;
420
435
  // A context's certificate setting is fixed when it is made, so an explicit request for the
421
436
  // other one cannot be answered by the held lease: handing it back would keep checks off after
@@ -475,6 +490,7 @@ export const LEASE_ACQUIRE_TOOL = {
475
490
  // Asked of the pool, not remembered here: the stub was installed when THIS lease was
476
491
  // minted, and the caller reusing it now may be seeing it for the first time.
477
492
  ...(true === pool.stubInstalled?.(existing) ? { hostStub: HOST_STUB_NOTE } : {}),
493
+ ...cspWidenedOf(pool, existing),
478
494
  ...(alreadyOpen === undefined
479
495
  ? {}
480
496
  : { preferExisting: { sessionId: alreadyOpen, note: PREFER_EXISTING_NOTE } }),
@@ -526,12 +542,18 @@ export const LEASE_ACQUIRE_TOOL = {
526
542
  // Resolved rather than assumed: an app that names its own session registers under that name,
527
543
  // and the id we hand back has to be the one the agent can actually drive.
528
544
  let registeredId;
529
- const waitStartedAt = Date.now();
530
- const waited = await production.guardedForProduction(pool, lease, activated);
531
- const { ready, zeroInstall, stillLoading, readerFailure } = await connectOrInject(waited, () => {
545
+ const isRegistered = () => {
532
546
  registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
533
547
  return registeredId !== undefined;
534
- });
548
+ };
549
+ const waitStartedAt = deps.now();
550
+ const deadline = waitStartedAt + (asNumber(args['timeout_ms']) ?? LEASE_READY_TIMEOUT_MS);
551
+ const waited = await production.guardedForProduction(pool, lease, activated);
552
+ const { ready: connected, zeroInstall, stillLoading, readerFailure, } = await connectOrInject(waited, isRegistered, connectWaitUntil(deadline, deps.now));
553
+ // Connected is a row in the sessions map. Ready is a reply from it, so the next call works.
554
+ const answerReason = connected
555
+ ? await awaitLeaseAnswering(() => (isRegistered() ? deps.sessions.get(registeredId ?? lease.sessionId) : undefined), deadline, deps.now)
556
+ : undefined;
535
557
  // Tell the pool the other name this lease answers to. Every later touch and release arrives
536
558
  // under the id we are about to hand back, and the pool is keyed by the id it navigated with;
537
559
  // without this the touches miss, the lease ages out despite continuous activity, and the
@@ -541,6 +563,7 @@ export const LEASE_ACQUIRE_TOOL = {
541
563
  }
542
564
  const effectiveSessionId = registeredId ?? lease.sessionId;
543
565
  const session = deps.sessions.get(effectiveSessionId);
566
+ const ready = connected && answerReason === undefined;
544
567
  if (validatedSeed !== undefined && session !== undefined) {
545
568
  const failureReason = evaluateSeedPrecondition(url, session.url, validatedSeed, lease.navStatus);
546
569
  if (failureReason !== undefined) {
@@ -552,18 +575,17 @@ export const LEASE_ACQUIRE_TOOL = {
552
575
  if (readerFailure === undefined) {
553
576
  tellWatchers(deps, projectId, AGENT_DRIVING_ELSEWHERE);
554
577
  }
555
- // ready means the SDK dialled in — not that contracts match. Carry the skew warning on acquire
578
+ // ready means the SDK answers — not that contracts match. Carry the skew warning on acquire
556
579
  // so the agent does not learn it only after a CDP tool invents a closed page (#688).
557
- const mintReason = readerFailure?.reason ??
558
- notReadyReason(ready, session, LeaseNotReadyReason.SDK_NEVER_DIALLED);
580
+ const mintReason = readerFailure?.reason ?? (connected ? answerReason : LeaseNotReadyReason.SDK_NEVER_DIALLED);
559
581
  const versionSkew = registeredId === undefined ? undefined : deps.sessions.get(registeredId)?.versionSkew;
560
582
  let notReadyHint;
561
- if (!ready) {
583
+ if (!connected) {
562
584
  if (readerFailure !== undefined) {
563
585
  notReadyHint = leaseReaderFailedHint(url, readerFailure);
564
586
  }
565
587
  else if (true === stillLoading) {
566
- notReadyHint = leaseStillLoadingHint(url, Date.now() - waitStartedAt);
588
+ notReadyHint = leaseStillLoadingHint(url, deps.now() - waitStartedAt);
567
589
  }
568
590
  else if (activated !== undefined) {
569
591
  notReadyHint = production.notActivatedHint(url);
@@ -596,6 +618,7 @@ export const LEASE_ACQUIRE_TOOL = {
596
618
  ...(versionSkew === undefined ? {} : { versionSkew }),
597
619
  ...production.summary(activated),
598
620
  ...(initScript === undefined ? {} : { hostStub: HOST_STUB_NOTE }),
621
+ ...cspWidenedOf(pool, lease.sessionId),
599
622
  ...hintOf(notReadyHint, ready ? await notificationReadBack(pool, lease.sessionId, permissions) : undefined),
600
623
  };
601
624
  }
@@ -607,6 +630,11 @@ export const LEASE_ACQUIRE_TOOL = {
607
630
  }
608
631
  },
609
632
  };
633
+ /** `cspWidenedFor` when the lease changed its page CSP: a change to the page is declared (#2016). */
634
+ function cspWidenedOf(pool, leaseId) {
635
+ const origin = pool.cspWidenedFor?.(leaseId);
636
+ return origin === undefined ? {} : { cspWidenedFor: origin };
637
+ }
610
638
  /** The whole not-connected diagnosis, gathered and worded. Split out so the acquire path stays flat. */
611
639
  async function notConnectedHint(deps, url, dialledUrl) {
612
640
  const bridgePort = deps.bridgePort ?? RETICLE_DEFAULT_PORT;
@@ -0,0 +1,40 @@
1
+ /** The call in a streak that gets the hint. Two calls can be a deliberate retry; three is a loop. */
2
+ export declare const REPEAT_HINT_AT = 3;
3
+ export declare const RepeatHint: {
4
+ readonly NO_MATCH: string;
5
+ /** A no_match whose refusal listed the nearest targets: the retry is one of those refs. */
6
+ readonly noMatchNearest: (ref: string) => string;
7
+ readonly LOOK_SAME: string;
8
+ readonly RECOVERY: string;
9
+ readonly SAME: `You made this call ${string} times in a row with the same result.`;
10
+ /** A verdict repeated: name its own reason, the only thing the result says about why. */
11
+ readonly verdict: (verified: string, because: string | undefined) => string;
12
+ };
13
+ /** What a call came back with, as far as a repeat is concerned: an identity and the line to say. */
14
+ export interface RepeatOutcome {
15
+ readonly id: string;
16
+ readonly hint: string;
17
+ }
18
+ /** The outcome of a call that threw: its refusal reason. */
19
+ export declare function outcomeOfError(error: unknown): RepeatOutcome;
20
+ /** The outcome of a call that answered: its refusal reason, its verdict, or its whole answer. */
21
+ export declare function outcomeOfResult(tool: string, result: unknown): RepeatOutcome;
22
+ /** Tests only. */
23
+ export declare function resetRepeatHints(): void;
24
+ /**
25
+ * Note one call of one connection, and answer the hint when this call is the third in a streak.
26
+ * `owner` is the agent connection; undefined (the Harness, the HUD, an embedding) is never counted.
27
+ */
28
+ export declare function noteRepeat(owner: string | undefined, tool: string, args: Record<string, unknown>, outcome: RepeatOutcome): string | undefined;
29
+ /**
30
+ * Run one call of one connection inside its repeat streak. Answers the result and, on the third
31
+ * identical call, the hint. A call that throws keeps its hint beside the error (`withRepeatHint`).
32
+ */
33
+ export declare function inRepeatStreak(owner: string | undefined, tool: string, args: Record<string, unknown>, run: () => Promise<unknown>): Promise<{
34
+ raw: unknown;
35
+ hint: string | undefined;
36
+ }>;
37
+ /** The answer with the hint beside it. Extra text only: the verdict and every field stay as they are. */
38
+ export declare function withHintOn(answer: unknown, hint: string | undefined): unknown;
39
+ /** The error payload with the hint its call earned, if any. */
40
+ export declare function withRepeatHint<T extends object>(payload: T, error: unknown): T;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * One hint when an agent makes the same call, with the same result, for the third time in a row.
3
+ *
4
+ * Agents in the field made the same call five times or more in a row. Each answer was the same, and
5
+ * nothing in it said so. This module remembers the LAST call of each agent connection and gives one
6
+ * line, on the third identical call, that says what to try instead.
7
+ *
8
+ * Advice only: it reads a result that is already decided and never changes or blocks it. A hint says
9
+ * only what the result supports. When there is nothing specific to say, it says that the call was
10
+ * repeated with the same result.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { RefusalReason, ReticleTool, isPlainRecord } from '@reticlehq/core';
14
+ import { resultIsError } from '../mcp/faults/mcp-is-error.js';
15
+ import { buildErrorPayload, codedRefusalOf, refusalReasonFor } from './error-recovery.js';
16
+ import { EnvelopeKey } from './tool-kit.js';
17
+ import { NEAREST_TARGETS_LEAD } from './act/resolve-target.js';
18
+ /** The call in a streak that gets the hint. Two calls can be a deliberate retry; three is a loop. */
19
+ export const REPEAT_HINT_AT = 3;
20
+ const COUNT = String(REPEAT_HINT_AT);
21
+ export const RepeatHint = {
22
+ NO_MATCH: `You made this call ${COUNT} times and each time no element matched. Do not send it again. ` +
23
+ 'Take one reticle_look { action: "find" } and use a ref from its answer.',
24
+ /** A no_match whose refusal listed the nearest targets: the retry is one of those refs. */
25
+ noMatchNearest: (ref) => `You made this call ${COUNT} times and each time no element matched. Do not send it again. ` +
26
+ `The refusal lists the nearest targets. Pass one of their refs as \`ref\`, for example "${ref}".`,
27
+ LOOK_SAME: `You made this look ${COUNT} times and each answer was the same, so the page did not change. ` +
28
+ 'Act on the page, or wait for a change with reticle_assert { action: "wait" }.',
29
+ RECOVERY: `You made this call ${COUNT} times and it was refused for the same reason each time. ` +
30
+ 'Do the step in `recovery` before you send it again.',
31
+ SAME: `You made this call ${COUNT} times in a row with the same result.`,
32
+ /** A verdict repeated: name its own reason, the only thing the result says about why. */
33
+ verdict: (verified, because) => `You made this call ${COUNT} times and each verdict was "${verified}".` +
34
+ (because === undefined ? '' : ` Its reason: "${because}".`) +
35
+ ' The same call gives the same verdict. Change the target, the action or the `until`.',
36
+ };
37
+ /**
38
+ * Result keys that change from call to call when nothing else did: the dispatcher's own advisories,
39
+ * the screenshot path, and the clock. A key that is missed here only means no hint fires.
40
+ */
41
+ const VOLATILE_KEYS = new Set([
42
+ ...Object.values(EnvelopeKey),
43
+ 'cursor',
44
+ 'since',
45
+ 'at',
46
+ 'ms',
47
+ 'elapsedMs',
48
+ 'durationMs',
49
+ ]);
50
+ /** The same JSON for the same value, whatever the key order. */
51
+ function stableJson(value) {
52
+ if (Array.isArray(value)) {
53
+ return `[${value.map(stableJson).join(',')}]`;
54
+ }
55
+ if (isPlainRecord(value)) {
56
+ const keys = Object.keys(value)
57
+ .filter((key) => value[key] !== undefined)
58
+ .sort();
59
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
60
+ }
61
+ return JSON.stringify(value) ?? 'undefined';
62
+ }
63
+ /** A short digest, so a connection keeps a few bytes and not a whole page tree. */
64
+ function digest(text) {
65
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
66
+ }
67
+ /** The first ref in a no_match refusal's nearest-target list, or undefined when it has no list. */
68
+ function firstNearestRef(message) {
69
+ const at = message.indexOf(NEAREST_TARGETS_LEAD);
70
+ if (-1 === at) {
71
+ return undefined;
72
+ }
73
+ return /^(\S+) \(/.exec(message.slice(at + NEAREST_TARGETS_LEAD.length))?.[1];
74
+ }
75
+ function refusal(reason, recovery, message) {
76
+ const id = `refused:${reason}`;
77
+ if (RefusalReason.NO_MATCH === reason) {
78
+ const ref = firstNearestRef(message);
79
+ return { id, hint: ref === undefined ? RepeatHint.NO_MATCH : RepeatHint.noMatchNearest(ref) };
80
+ }
81
+ return { id, hint: 'string' === typeof recovery ? RepeatHint.RECOVERY : RepeatHint.SAME };
82
+ }
83
+ /** The outcome of a call that threw: its refusal reason. */
84
+ export function outcomeOfError(error) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ const coded = codedRefusalOf(error);
87
+ return refusal(refusalReasonFor(message, coded), buildErrorPayload(message, coded).recovery, message);
88
+ }
89
+ /** The outcome of a call that answered: its refusal reason, its verdict, or its whole answer. */
90
+ export function outcomeOfResult(tool, result) {
91
+ if (!isPlainRecord(result)) {
92
+ return { id: `value:${digest(stableJson(result))}`, hint: RepeatHint.SAME };
93
+ }
94
+ if (resultIsError(result)) {
95
+ const message = String(result['error']);
96
+ return refusal(refusalReasonFor(message), result['recovery'], message);
97
+ }
98
+ if ('verified' in result) {
99
+ const verified = String(result['verified']);
100
+ const because = 'string' === typeof result['because'] ? result['because'] : undefined;
101
+ return {
102
+ id: `verdict:${verified}:${because ?? ''}`,
103
+ hint: RepeatHint.verdict(verified, because),
104
+ };
105
+ }
106
+ const kept = Object.fromEntries(Object.entries(result).filter(([key]) => !VOLATILE_KEYS.has(key)));
107
+ return {
108
+ id: `answer:${digest(stableJson(kept))}`,
109
+ hint: ReticleTool.LOOK === tool ? RepeatHint.LOOK_SAME : RepeatHint.SAME,
110
+ };
111
+ }
112
+ /** Bound on remembered connections: a daemon outlives them. Each keeps only its last call. */
113
+ const MAX_CONNECTIONS = 500;
114
+ const lastByConnection = new Map();
115
+ /** Tests only. */
116
+ export function resetRepeatHints() {
117
+ lastByConnection.clear();
118
+ }
119
+ /**
120
+ * Note one call of one connection, and answer the hint when this call is the third in a streak.
121
+ * `owner` is the agent connection; undefined (the Harness, the HUD, an embedding) is never counted.
122
+ */
123
+ export function noteRepeat(owner, tool, args, outcome) {
124
+ if (owner === undefined) {
125
+ return undefined;
126
+ }
127
+ const call = digest(`${tool}:${stableJson(args)}`);
128
+ const last = lastByConnection.get(owner);
129
+ const count = last?.call === call && last.outcome === outcome.id ? last.count + 1 : 1;
130
+ // Delete first, so the map order is last use and the oldest connection is the one that goes.
131
+ lastByConnection.delete(owner);
132
+ if (lastByConnection.size >= MAX_CONNECTIONS) {
133
+ const oldest = lastByConnection.keys().next().value;
134
+ if (oldest !== undefined) {
135
+ lastByConnection.delete(oldest);
136
+ }
137
+ }
138
+ lastByConnection.set(owner, { call, outcome: outcome.id, count });
139
+ return REPEAT_HINT_AT === count ? outcome.hint : undefined;
140
+ }
141
+ /** Hints for calls that threw, kept beside the error so the boundary that answers can read them. */
142
+ const hintByError = new WeakMap();
143
+ /**
144
+ * Run one call of one connection inside its repeat streak. Answers the result and, on the third
145
+ * identical call, the hint. A call that throws keeps its hint beside the error (`withRepeatHint`).
146
+ */
147
+ export async function inRepeatStreak(owner, tool, args, run) {
148
+ let raw;
149
+ try {
150
+ raw = await run();
151
+ }
152
+ catch (error) {
153
+ const hint = noteRepeat(owner, tool, args, outcomeOfError(error));
154
+ if (hint !== undefined && 'object' === typeof error && null !== error) {
155
+ hintByError.set(error, hint);
156
+ }
157
+ throw error;
158
+ }
159
+ return { raw, hint: noteRepeat(owner, tool, args, outcomeOfResult(tool, raw)) };
160
+ }
161
+ /** The answer with the hint beside it. Extra text only: the verdict and every field stay as they are. */
162
+ export function withHintOn(answer, hint) {
163
+ return hint !== undefined && isPlainRecord(answer)
164
+ ? { ...answer, [EnvelopeKey.REPEAT_HINT]: hint }
165
+ : answer;
166
+ }
167
+ /** The error payload with the hint its call earned, if any. */
168
+ export function withRepeatHint(payload, error) {
169
+ const hint = 'object' === typeof error && null !== error ? hintByError.get(error) : undefined;
170
+ return hint === undefined ? payload : { ...payload, [EnvelopeKey.REPEAT_HINT]: hint };
171
+ }
@@ -14,8 +14,8 @@ export declare function renderRunReport(report: RunReport): string;
14
14
  export declare function endsRun(tool: string, args: Record<string, unknown>): boolean;
15
15
  /** What an answer says the run saved or read. An answer that refused counts for nothing. */
16
16
  export declare function noteToolResult(ledger: RunLedger, tool: string, args: Record<string, unknown>, result: unknown): void;
17
- /** The report as an answer field, or no field when the ledger holds no run. Empties the ledger. */
18
- export declare function runReportField(ledger: RunLedger, root: string | undefined, env: Record<string, string | undefined>, now: number): Partial<Record<typeof RUN_REPORT_FIELD, {
17
+ /** The owner's report as an answer field, or no field when the owner has no run. Ends that run. */
18
+ export declare function runReportField(ledger: RunLedger, root: string | undefined, env: Record<string, string | undefined>, now: number, owner?: string): Partial<Record<typeof RUN_REPORT_FIELD, {
19
19
  report: RunReport;
20
20
  markdown: string;
21
21
  instruction: string;
@@ -34,12 +34,15 @@ function headline(report) {
34
34
  const count = (v) => all.filter((each) => v === each).length;
35
35
  const total = all.length;
36
36
  const word = `${HEAD}${WORD[report.verified]}**${SEP}`;
37
- if (0 === total)
37
+ if (0 === total) {
38
38
  return `${word}${Said.NOTHING_CHECKED}`;
39
- if (Verified.NO === report.verified)
39
+ }
40
+ if (Verified.NO === report.verified) {
40
41
  return `${word}${String(count(Verified.NO))} of ${plural(total, 'check')} did not hold`;
41
- if (Verified.YES === report.verified)
42
+ }
43
+ if (Verified.YES === report.verified) {
42
44
  return `${word}${String(total)} of ${plural(total, 'check')} held`;
45
+ }
43
46
  return `${word}${String(total - count(Verified.YES) - count(Verified.NO))} of ${plural(total, 'check')} undecided`;
44
47
  }
45
48
  /** The report as markdown: the verdict, each claim with its evidence, then what the run left. */
@@ -95,39 +98,48 @@ const INTENT_READS = new Set(['list', 'index', 'subject', 'get']);
95
98
  const isRecord = (value) => null !== value && 'object' === typeof value && !Array.isArray(value);
96
99
  /** What an answer says the run saved or read. An answer that refused counts for nothing. */
97
100
  export function noteToolResult(ledger, tool, args, result) {
98
- if (!isRecord(result) || result['error'] !== undefined)
101
+ if (!isRecord(result) || result['error'] !== undefined) {
99
102
  return;
103
+ }
100
104
  if (ReticleTool.FLOW_SAVE === tool || ReticleTool.FLOW_SAVE_RECORDED === tool) {
101
105
  const name = result['name'];
102
- if ('string' === typeof name)
106
+ if ('string' === typeof name) {
103
107
  ledger.flowSaved(name);
108
+ }
104
109
  return;
105
110
  }
106
111
  const action = asString(args['action']);
107
112
  if (ReticleTool.INTENT === tool) {
108
113
  // The intent ledger in `.reticle/intent/` is the memory an unlinked project has.
109
- if (IntentWrite.RECORD === action)
114
+ if (IntentWrite.RECORD === action) {
110
115
  ledger.memoryWritten();
116
+ }
111
117
  const declared = args['intents'];
112
- if (IntentWrite.DECLARE === action && Array.isArray(declared))
118
+ if (IntentWrite.DECLARE === action && Array.isArray(declared)) {
113
119
  ledger.memoryWritten(declared.length);
114
- if (action !== undefined && INTENT_READS.has(action))
120
+ }
121
+ if (action !== undefined && INTENT_READS.has(action)) {
115
122
  ledger.memoryRead([JSON.stringify(result)]);
123
+ }
116
124
  return;
117
125
  }
118
- if (ReticleTool.MEMORY !== tool)
126
+ if (ReticleTool.MEMORY !== tool) {
119
127
  return;
120
- if ('add' === action && result['added'] !== undefined)
128
+ }
129
+ if ('add' === action && result['added'] !== undefined) {
121
130
  ledger.memoryWritten();
131
+ }
122
132
  const known = result['known'];
123
- if (Array.isArray(known))
133
+ if (Array.isArray(known)) {
124
134
  ledger.memoryRead(known.map((entry) => JSON.stringify(entry)));
135
+ }
125
136
  }
126
- /** The report as an answer field, or no field when the ledger holds no run. Empties the ledger. */
127
- export function runReportField(ledger, root, env, now) {
128
- if (ledger.isEmpty())
137
+ /** The owner's report as an answer field, or no field when the owner has no run. Ends that run. */
138
+ export function runReportField(ledger, root, env, now, owner) {
139
+ if (ledger.isEmpty(owner)) {
129
140
  return {};
130
- const taken = ledger.take(now);
141
+ }
142
+ const taken = ledger.take(now, owner);
131
143
  const silenced = env[UPGRADE_HINT_SILENCE_ENV] !== undefined;
132
144
  const linked = root === undefined || linkedToPlatform(root, env);
133
145
  const report = silenced || linked ? taken : { ...taken, cloud: CLOUD_VALUE_LINE };
@@ -96,6 +96,11 @@ export interface ToolDeps<Ext = unknown> {
96
96
  } | undefined>;
97
97
  /** The one dev server running in the caller's project folder, for a lease that names no url. */
98
98
  projectAppUrl?: () => Promise<string | undefined>;
99
+ /**
100
+ * The first-session offer for this attach id's first call, when no app is attached and the
101
+ * project is not wired (see zero-install-first-call.ts). Absent: no offer.
102
+ */
103
+ firstSessionOffer?: (attachId: string) => string | undefined;
99
104
  /** cross-run outcome memory (.reticle/project.json). */
100
105
  project: ProjectStore;
101
106
  /** optional native-input provider. undefined ⇒ everything stays synthetic. */
@@ -303,10 +308,17 @@ export declare const EnvelopeKey: {
303
308
  * what it cannot see, and the command that wires the app for good. See zero-install-first-call.ts.
304
309
  */
305
310
  readonly ZERO_INSTALL: "zero_install";
311
+ /**
312
+ * Once per agent connection, on its first call: this unwired web app can be checked now with the
313
+ * lease, and `init` needs the user's consent. See zero-install-first-call.ts.
314
+ */
315
+ readonly FIRST_SESSION_OFFER: "first_session_offer";
306
316
  /** Once-per-session advisory lines an agent's own call carries. See agent/notices.ts. */
307
317
  readonly NOTICES: "notices";
308
318
  /** The end-of-run report, on the call that ends an agent's run. See run-report.ts. */
309
319
  readonly RUN_REPORT: "runReport";
320
+ /** On the third identical call with the same result: what to try instead. See repeat-hint.ts. */
321
+ readonly REPEAT_HINT: "repeatHint";
310
322
  };
311
323
  export type EnvelopeKey = (typeof EnvelopeKey)[keyof typeof EnvelopeKey];
312
324
  /**
@@ -99,10 +99,17 @@ export const EnvelopeKey = {
99
99
  * what it cannot see, and the command that wires the app for good. See zero-install-first-call.ts.
100
100
  */
101
101
  ZERO_INSTALL: 'zero_install',
102
+ /**
103
+ * Once per agent connection, on its first call: this unwired web app can be checked now with the
104
+ * lease, and `init` needs the user's consent. See zero-install-first-call.ts.
105
+ */
106
+ FIRST_SESSION_OFFER: 'first_session_offer',
102
107
  /** Once-per-session advisory lines an agent's own call carries. See agent/notices.ts. */
103
108
  NOTICES: 'notices',
104
109
  /** The end-of-run report, on the call that ends an agent's run. See run-report.ts. */
105
110
  RUN_REPORT: RUN_REPORT_FIELD,
111
+ /** On the third identical call with the same result: what to try instead. See repeat-hint.ts. */
112
+ REPEAT_HINT: 'repeatHint',
106
113
  };
107
114
  /**
108
115
  * The sentence every session-bound result carries while its leased tab runs on a stubbed shell.
@@ -1,7 +1,7 @@
1
1
  import { TESTID_FOUND_UNDER_SCHEMA } from './query-hint-schema.js';
2
2
  import { z } from 'zod';
3
- import { EMPTY_SESSION_LIST_OUTPUT, emptySessionList, nextStepBesideLiveSessions, } from './empty-session-list.js';
4
- import { AttrNamesSchema, QueryBy, ReticleCommand, SnapshotMode } from '@reticlehq/core';
3
+ import { EMPTY_SESSION_LIST_OUTPUT, emptySessionList, NEXT_STEP_FIELD, nextStepBesideLiveSessions, } from './empty-session-list.js';
4
+ import { AttrNamesSchema, NearestTargetSchema, QueryBy, ReticleCommand, SnapshotMode, } from '@reticlehq/core';
5
5
  import { ReticleTool } from '@reticlehq/core';
6
6
  import { withSizeCost } from '../../portal/session/output-budget.js';
7
7
  import { applySnapshotDelta, SnapshotCache } from './read/snapshot-delta.js';
@@ -145,11 +145,12 @@ export const RAW_TOOLS = withCompactEnvelope([
145
145
  })));
146
146
  const sessions = await list();
147
147
  // An empty list is a dead end on its own; see empty-session-list.ts for what it carries.
148
- if (0 === sessions.length)
148
+ if (0 === sessions.length) {
149
149
  return emptySessionList(deps, sessions);
150
+ }
150
151
  // Another project's tab is up. This caller's project may still never have been wired.
151
152
  const nextStep = nextStepBesideLiveSessions(deps, sessions);
152
- return nextStep === undefined ? { sessions } : { sessions, next_step: nextStep };
153
+ return nextStep === undefined ? { sessions } : { sessions, [NEXT_STEP_FIELD]: nextStep };
153
154
  },
154
155
  },
155
156
  {
@@ -284,7 +285,7 @@ export const RAW_TOOLS = withCompactEnvelope([
284
285
  name: z
285
286
  .string()
286
287
  .optional()
287
- .describe('Accessible name filter — narrows results when `by` is role and the page has many elements of that role.'),
288
+ .describe('Exact accessible-name filter - narrows results when `by` is role and the page has many elements of that role. Matches the whole name; use `text` for a partial match.'),
288
289
  scope: z
289
290
  .string()
290
291
  .optional()
@@ -398,6 +399,10 @@ export const RAW_TOOLS = withCompactEnvelope([
398
399
  .optional()
399
400
  .describe('Present when a ROLE+NAME search missed and that role DOES carry a nearly-matching name — role+name is exact, so "Mesh" does not find "2 Mesh". Retry with one of these spellings; no snapshot needed.'),
400
401
  testidFoundUnder: TESTID_FOUND_UNDER_SCHEMA,
402
+ nearest: z
403
+ .array(NearestTargetSchema)
404
+ .optional()
405
+ .describe('Present when a search missed and the page has visible targets like the one asked for: up to 3, the asked role first, then by name similarity. Each has a ref to retry with. nameTruncated is true when the name was cut. Advice only, never a match.'),
401
406
  })
402
407
  .optional()
403
408
  .describe('Present only on zero matches — tells you what IS on the page so you can diagnose the miss.'),
@@ -422,8 +427,9 @@ export const RAW_TOOLS = withCompactEnvelope([
422
427
  // Accept the predicate's named-field spelling too — see query-shape.ts.
423
428
  const args = normalizeQueryArgs(rawArgs);
424
429
  const empty = emptyQueryRefusal(args);
425
- if (empty !== undefined)
430
+ if (empty !== undefined) {
426
431
  return Promise.reject(empty);
432
+ }
427
433
  const session = deps.sessions.resolve(asString(args['sessionId']));
428
434
  return commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.QUERY, {
429
435
  by: args['by'],
@@ -603,14 +609,17 @@ export const RETIRED_FROM_SURFACE = [
603
609
  * The browser counts what leanness passed over, so the tool can say which of the two it is.
604
610
  */
605
611
  function noteEmptyLeanTree(result, mode) {
606
- if (SnapshotMode.INTERACTIVE !== mode)
612
+ if (SnapshotMode.INTERACTIVE !== mode) {
607
613
  return result;
614
+ }
608
615
  const row = asRecord(result);
609
- if (0 !== asNumber(row['nodes']))
616
+ if (0 !== asNumber(row['nodes'])) {
610
617
  return result;
618
+ }
611
619
  const skipped = asNumber(row['leanSkipped']) ?? 0;
612
- if (0 === skipped)
620
+ if (0 === skipped) {
613
621
  return result;
622
+ }
614
623
  return {
615
624
  ...row,
616
625
  note: `no element on this page carries an interactive ARIA role, so this mode found nothing — ` +
@@ -635,18 +644,22 @@ function noteEmptyLeanTree(result, mode) {
635
644
  * empty tree it replaces.
636
645
  */
637
646
  function noteHiddenPage(result, mode) {
638
- if (SnapshotMode.STATUS === mode)
647
+ if (SnapshotMode.STATUS === mode) {
639
648
  return result;
649
+ }
640
650
  const row = asRecord(result);
641
- if (0 !== asNumber(row['nodes']))
651
+ if (0 !== asNumber(row['nodes'])) {
642
652
  return result;
653
+ }
643
654
  // A note already there is the more specific one (leanness), and two explanations for one empty
644
655
  // tree is worse than the better of them alone.
645
- if (row['note'] !== undefined)
656
+ if (row['note'] !== undefined) {
646
657
  return result;
658
+ }
647
659
  const skipped = asNumber(row['hiddenSkipped']) ?? 0;
648
- if (0 === skipped)
660
+ if (0 === skipped) {
649
661
  return result;
662
+ }
650
663
  return {
651
664
  ...row,
652
665
  note: `this is NOT an empty page: ${String(skipped)} subtree(s) were skipped because their root ` +