@reticlehq/server 3.8.1 → 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 (231) 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/drive-error.d.ts +6 -0
  114. package/dist/portal/input/drive-error.js +9 -0
  115. package/dist/portal/input/network-mock.js +3 -2
  116. package/dist/portal/input/real-input-press.d.ts +34 -0
  117. package/dist/portal/input/real-input-press.js +150 -0
  118. package/dist/portal/input/real-input.d.ts +21 -23
  119. package/dist/portal/input/real-input.js +153 -194
  120. package/dist/portal/input/viewport-tools.js +6 -3
  121. package/dist/portal/pool/browser-pool.d.ts +5 -1
  122. package/dist/portal/pool/browser-pool.js +32 -38
  123. package/dist/portal/pool/context-permissions.d.ts +20 -17
  124. package/dist/portal/pool/context-permissions.js +63 -1
  125. package/dist/portal/pool/csp-widen.d.ts +62 -0
  126. package/dist/portal/pool/csp-widen.js +132 -0
  127. package/dist/portal/pool/lease-page-error.d.ts +29 -0
  128. package/dist/portal/pool/lease-page-error.js +18 -0
  129. package/dist/portal/pool/page-error-journal.d.ts +57 -0
  130. package/dist/portal/pool/page-error-journal.js +181 -0
  131. package/dist/portal/pool/playwright-launcher.js +21 -8
  132. package/dist/portal/pool/pool-contract.d.ts +22 -0
  133. package/dist/portal/pool/pool-lease.d.ts +35 -2
  134. package/dist/portal/pool/pool-lease.js +23 -0
  135. package/dist/portal/pool/zero-install.js +69 -1
  136. package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
  137. package/dist/portal/session/dev-server/dev-command.js +56 -8
  138. package/dist/portal/session/no-session-diagnosis.js +10 -3
  139. package/dist/portal/session/no-session-next-action.d.ts +12 -0
  140. package/dist/portal/session/no-session-next-action.js +44 -4
  141. package/dist/portal/session/no-session-watch.js +3 -1
  142. package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
  143. package/dist/portal/session/page-commands/undeclared-command.js +16 -7
  144. package/dist/portal/session/recorded-gaps.d.ts +1 -1
  145. package/dist/portal/session/recorded-gaps.js +5 -2
  146. package/dist/portal/session/session-manager.js +40 -21
  147. package/dist/portal/session/session.js +4 -3
  148. package/dist/portal/session/zero-install-first-call.d.ts +20 -1
  149. package/dist/portal/session/zero-install-first-call.js +34 -2
  150. package/dist/start-wiring.d.ts +29 -2
  151. package/dist/start-wiring.js +73 -9
  152. package/dist/surface/mcp/mcp-proxy.js +10 -2
  153. package/dist/surface/mcp/mcp.js +5 -3
  154. package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
  155. package/dist/surface/mcp/peer/agents-connected.js +58 -0
  156. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
  157. package/dist/surface/tools/act/act-preflight.js +37 -17
  158. package/dist/surface/tools/act/act-target.js +3 -1
  159. package/dist/surface/tools/act/after-match.d.ts +7 -0
  160. package/dist/surface/tools/act/after-match.js +33 -0
  161. package/dist/surface/tools/act/resolve-target.d.ts +2 -0
  162. package/dist/surface/tools/act/resolve-target.js +35 -8
  163. package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
  164. package/dist/surface/tools/act/sequence-grade.js +8 -1
  165. package/dist/surface/tools/act-sequence-tool.js +3 -0
  166. package/dist/surface/tools/act-tools.js +12 -12
  167. package/dist/surface/tools/assert/assert-verdict.js +28 -1
  168. package/dist/surface/tools/browser-tools.js +75 -7
  169. package/dist/surface/tools/dynamic-tools.js +2 -1
  170. package/dist/surface/tools/empty-session-list.d.ts +2 -0
  171. package/dist/surface/tools/empty-session-list.js +4 -2
  172. package/dist/surface/tools/error-recovery.js +43 -22
  173. package/dist/surface/tools/invoke-tool.js +27 -20
  174. package/dist/surface/tools/lease-hint.d.ts +7 -0
  175. package/dist/surface/tools/lease-hint.js +10 -3
  176. package/dist/surface/tools/lease-readiness.d.ts +21 -0
  177. package/dist/surface/tools/lease-readiness.js +47 -0
  178. package/dist/surface/tools/lease-seed.d.ts +2 -0
  179. package/dist/surface/tools/lease-seed.js +2 -1
  180. package/dist/surface/tools/lease-tools.js +43 -15
  181. package/dist/surface/tools/real-input-attempt.js +70 -31
  182. package/dist/surface/tools/repeat-hint.d.ts +40 -0
  183. package/dist/surface/tools/repeat-hint.js +171 -0
  184. package/dist/surface/tools/run-report.d.ts +2 -2
  185. package/dist/surface/tools/run-report.js +27 -15
  186. package/dist/surface/tools/tool-kit.d.ts +12 -0
  187. package/dist/surface/tools/tool-kit.js +7 -0
  188. package/dist/surface/tools/tools.js +26 -13
  189. package/dist/telemetry/argument-shape.js +16 -7
  190. package/dist/telemetry/cli-telemetry.d.ts +10 -1
  191. package/dist/telemetry/cli-telemetry.js +15 -0
  192. package/dist/telemetry/init-telemetry.d.ts +2 -1
  193. package/dist/telemetry/init-telemetry.js +47 -2
  194. package/dist/telemetry/install-trace.js +23 -4
  195. package/dist/telemetry/onboarding-funnel.d.ts +19 -2
  196. package/dist/telemetry/onboarding-funnel.js +25 -3
  197. package/dist/telemetry/proxy-telemetry.d.ts +2 -1
  198. package/dist/telemetry/proxy-telemetry.js +2 -2
  199. package/dist/telemetry/session-metrics.d.ts +1 -1
  200. package/dist/telemetry/session-metrics.js +67 -27
  201. package/dist/telemetry/telemetry.d.ts +3 -1
  202. package/dist/telemetry/telemetry.js +60 -28
  203. package/dist/telemetry/tool-refused.d.ts +2 -2
  204. package/dist/telemetry/tool-refused.js +9 -2
  205. package/docs/architecture.md +3 -6
  206. package/docs/cli/doctor.mdx +41 -2
  207. package/docs/cli/help.mdx +4 -2
  208. package/docs/cli/license.mdx +4 -5
  209. package/docs/cli/setup.mdx +13 -2
  210. package/docs/cli/status.mdx +4 -0
  211. package/docs/cli.mdx +1 -1
  212. package/docs/deploy-checks.md +25 -0
  213. package/docs/desktop.mdx +1 -1
  214. package/docs/enterprise.md +15 -20
  215. package/docs/environment.mdx +1 -0
  216. package/docs/first-drive.md +1 -1
  217. package/docs/install-manual.mdx +16 -1
  218. package/docs/packages/engine.mdx +1 -1
  219. package/docs/packages/react.mdx +14 -2
  220. package/docs/packages/server.mdx +1 -1
  221. package/docs/packages/tauri.mdx +1 -1
  222. package/docs/packages/test.mdx +1 -1
  223. package/docs/packages/vite-plugin.mdx +1 -0
  224. package/docs/packages.mdx +1 -1
  225. package/docs/platform-integration.md +1 -1
  226. package/docs/skill.md +1 -1
  227. package/docs/telemetry.md +4 -2
  228. package/package.json +14 -10
  229. package/LICENSE-ENTERPRISE +0 -37
  230. package/dist/features/ee/audit-log.d.ts +0 -14
  231. 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;
@@ -19,7 +19,7 @@
19
19
  * the base64 4/3 inflation factor applied, so the encoded payload always fits in one WebSocket
20
20
  * frame.
21
21
  */
22
- import { ActionType, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
22
+ import { ActionType, clickInputProblem, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
23
23
  import { boxCenter, DriveError, isRealInputAction, unspellablePressReason, } from '../../portal/input/real-input.js';
24
24
  import { assertDragNotDestructive, assertNotDestructive } from './act/act-danger.js';
25
25
  import { assertNativeAllowed } from '../../portal/production/production-drive.js';
@@ -101,10 +101,12 @@ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3
101
101
  * through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
102
102
  */
103
103
  function expandUserPath(rawPath, home) {
104
- if ('~' === rawPath)
104
+ if ('~' === rawPath) {
105
105
  return home;
106
- if (rawPath.startsWith('~/') || rawPath.startsWith('~\\'))
106
+ }
107
+ if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
107
108
  return join(home, rawPath.slice(2));
109
+ }
108
110
  return rawPath;
109
111
  }
110
112
  /** True when `real` is inside `root` after both have been realpath'd / resolved. */
@@ -132,15 +134,18 @@ async function readUploadRoots(projectRoot, fs, home) {
132
134
  catch {
133
135
  return [];
134
136
  }
135
- if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
137
+ if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed)) {
136
138
  return [];
139
+ }
137
140
  const listed = parsed['uploadRoots'];
138
- if (!Array.isArray(listed))
141
+ if (!Array.isArray(listed)) {
139
142
  return [];
143
+ }
140
144
  const roots = [];
141
145
  for (const entry of listed) {
142
- if ('string' !== typeof entry || 0 === entry.trim().length)
146
+ if ('string' !== typeof entry || 0 === entry.trim().length) {
143
147
  continue;
148
+ }
144
149
  roots.push(resolve(expandUserPath(entry.trim(), home)));
145
150
  }
146
151
  return roots;
@@ -191,11 +196,13 @@ async function resolveUploadPath(rawPath, projectRoot, fs, uploadRoots, home) {
191
196
  export async function rewriteUploadArgs(deps, action, innerArgs,
192
197
  /** Injected for tests; production uses the process home directory. */
193
198
  home = homedir()) {
194
- if (action !== ActionType.UPLOAD)
199
+ if (action !== ActionType.UPLOAD) {
195
200
  return innerArgs;
201
+ }
196
202
  const rawPath = asString(innerArgs['path']);
197
- if (rawPath === undefined)
198
- return innerArgs; // no path → browser handles as inline upload
203
+ if (rawPath === undefined) {
204
+ return innerArgs;
205
+ } // no path → browser handles as inline upload
199
206
  // Project root is one level above .reticle/
200
207
  const projectRoot = resolve(join(deps.reticleRoot, '..'));
201
208
  const uploadRoots = await readUploadRoots(projectRoot, deps.fs, home);
@@ -236,14 +243,17 @@ function synthetic(reason) {
236
243
  function performArgsOf(inner) {
237
244
  const performArgs = {};
238
245
  const value = asString(inner['value']);
239
- if (value !== undefined)
246
+ if (value !== undefined) {
240
247
  performArgs.value = value;
248
+ }
241
249
  const text = asString(inner['text']);
242
- if (text !== undefined)
250
+ if (text !== undefined) {
243
251
  performArgs.text = text;
252
+ }
244
253
  const key = asString(inner['key']);
245
- if (key !== undefined)
254
+ if (key !== undefined) {
246
255
  performArgs.key = key;
256
+ }
247
257
  const modifiers = inner['modifiers'];
248
258
  if (Array.isArray(modifiers)) {
249
259
  performArgs.modifiers = modifiers.filter((m) => 'string' === typeof m);
@@ -251,9 +261,14 @@ function performArgsOf(inner) {
251
261
  // A held key is the whole point of a hold-to-confirm control, and the synthetic path has honoured
252
262
  // `holdMs` all along. Not forwarding it made the real keyboard the WEAKER of the two paths: it
253
263
  // drove the key instantly and reported a hold that never held.
264
+ const button = asString(inner['button']);
265
+ if (button !== undefined) {
266
+ performArgs.button = button;
267
+ }
254
268
  const holdMs = asNumber(inner['holdMs']);
255
- if (holdMs !== undefined)
269
+ if (holdMs !== undefined) {
256
270
  performArgs.holdMs = holdMs;
271
+ }
257
272
  // `keys` and `code` are read but not forwarded: both are routed away by the caller — a multi-key
258
273
  // sequence and an explicit physical code have no driver spelling — so carrying them into the
259
274
  // provider would only invite it to guess at a gesture the caller did not ask for.
@@ -285,11 +300,13 @@ const ANCHOR_FIELDS = [
285
300
  */
286
301
  async function pressOnLease(deps, session, ref, inner) {
287
302
  const pool = deps.pool;
288
- if (pool === undefined || !pool.hasKeyboard(session.id))
303
+ if (pool === undefined || !pool.hasKeyboard(session.id)) {
289
304
  return undefined;
305
+ }
290
306
  const unspellable = unspellablePressReason('', inner);
291
- if (unspellable !== undefined)
307
+ if (unspellable !== undefined) {
292
308
  return synthetic(unspellable);
309
+ }
293
310
  // What the focused element is called, from the focus act's own report, so the saved step can
294
311
  // find it again: the real keyboard reports no element of its own.
295
312
  let anchor = {};
@@ -307,8 +324,9 @@ async function pressOnLease(deps, session, ref, inner) {
307
324
  }
308
325
  }
309
326
  const performed = await pool.pressLease(session.id, performArgsOf(inner));
310
- if (performed?.performed !== true)
327
+ if (performed?.performed !== true) {
311
328
  return undefined;
329
+ }
312
330
  return {
313
331
  result: {
314
332
  ...anchor,
@@ -382,15 +400,17 @@ async function hoverForReal(deps, session, ref, inner, provider) {
382
400
  if (box === undefined) {
383
401
  throw new Error(HOVER_NEEDS_POINTER_MSG);
384
402
  }
385
- if (!inView)
403
+ if (!inView) {
386
404
  throw new Error(HOVER_OFF_VIEWPORT_MSG);
405
+ }
387
406
  if (providerReady && provider !== undefined) {
388
407
  try {
389
408
  const performed = await provider.perform(session.url, ActionType.HOVER, box, {});
390
409
  // Hover always has a resolved box, so there is always a center to report: the provider's own
391
410
  // reading, or the box's if it reported none. Both are the same point, not a fabricated one.
392
- if (performed.performed)
411
+ if (performed.performed) {
393
412
  return hoverSucceeded(performed.center ?? boxCenter(box), scrolled);
413
+ }
394
414
  }
395
415
  catch {
396
416
  // A provider error used to fall back to synthetic dispatch. For hover that is a lie.
@@ -428,8 +448,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
428
448
  if (provider === undefined) {
429
449
  if (action === ActionType.PRESS) {
430
450
  const leased = await pressOnLease(deps, session, ref, inner);
431
- if (leased !== undefined)
451
+ if (leased !== undefined) {
432
452
  return leased;
453
+ }
433
454
  }
434
455
  // Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
435
456
  // noise on the most-used tool in the product. But an agent that passed native:true asked a
@@ -441,11 +462,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
441
462
  // one was asked for, instead of a different gesture being sent. See `unspellablePressReason`.
442
463
  if (action === ActionType.PRESS) {
443
464
  const unspellable = unspellablePressReason(ref, inner);
444
- if (unspellable !== undefined)
465
+ if (unspellable !== undefined) {
445
466
  return synthetic(unspellable);
467
+ }
446
468
  }
447
- if (!isRealInputAction(action))
448
- return synthetic(InputModeReason.NOT_POINTER); // fill/type stay synthetic
469
+ if (!isRealInputAction(action)) {
470
+ return synthetic(InputModeReason.NOT_POINTER);
471
+ } // fill/type stay synthetic
449
472
  // "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
450
473
  // even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
451
474
  // off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
@@ -453,6 +476,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
453
476
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
454
477
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
455
478
  }
479
+ // The page refuses the same arguments on the synthetic path. A native click must not drop one.
480
+ if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
481
+ const problem = clickInputProblem(action, inner);
482
+ if (problem !== undefined) {
483
+ throw new Error(`cannot ${action}: ${problem}`);
484
+ }
485
+ }
456
486
  // Under version skew CDP is unusable while DOM tools still work (#688). Surface the skew sentence
457
487
  // rather than "page not correlated" / a silent provider-error fallback — those send the agent
458
488
  // hunting a dead context that is not dead.
@@ -466,15 +496,17 @@ export async function tryRealInput(deps, session, ref, action, args) {
466
496
  if (action !== ActionType.PRESS && session.versionSkew !== undefined) {
467
497
  throw new Error(session.versionSkew);
468
498
  }
469
- if (!(await provider.isAvailableFor(session.url)))
499
+ if (!(await provider.isAvailableFor(session.url))) {
470
500
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
501
+ }
471
502
  const performArgs = performArgsOf(inner);
472
503
  // A document key has no element of its own: skip INSPECT and the box entirely.
473
504
  if (action === ActionType.PRESS) {
474
505
  try {
475
506
  const performed = await provider.perform(session.url, action, PRESS_HAS_NO_BOX, performArgs);
476
- if (!performed.performed)
507
+ if (!performed.performed) {
477
508
  return synthetic(InputModeReason.PROVIDER_DECLINED);
509
+ }
478
510
  // No `center`: a key addresses focus, not a point. The placeholder's zero happened nowhere.
479
511
  //
480
512
  // `heldMs` IS forwarded, and it is the whole reason this object is not just the three fields
@@ -521,13 +553,15 @@ export async function tryRealInput(deps, session, ref, action, args) {
521
553
  let box = asBox(inspected);
522
554
  // A box outside the viewport after the reveal is a gesture that would land on nothing. The
523
555
  // synthetic path scrolls and hit-tests on its own, so it is the honest answer.
524
- if (box === undefined || !source.inView)
556
+ if (box === undefined || !source.inView) {
525
557
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
558
+ }
526
559
  let toBox;
527
560
  if (action === ActionType.DRAG) {
528
561
  const toRef = asString(inner['toRef']);
529
- if (toRef === undefined)
562
+ if (toRef === undefined) {
530
563
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
564
+ }
531
565
  const target = await inspectRevealed(deps, session, toRef);
532
566
  const targetInspected = target.inspected;
533
567
  // A drag is judged on BOTH ends: dropping onto "Trash" is destructive however innocent the
@@ -549,12 +583,14 @@ export async function tryRealInput(deps, session, ref, action, args) {
549
583
  }
550
584
  }
551
585
  }
552
- if (toBox !== undefined)
586
+ if (toBox !== undefined) {
553
587
  performArgs.toBox = toBox;
588
+ }
554
589
  try {
555
590
  const performed = await provider.perform(session.url, action, box, performArgs);
556
- if (!performed.performed)
591
+ if (!performed.performed) {
557
592
  return synthetic(InputModeReason.PROVIDER_DECLINED);
593
+ }
558
594
  return {
559
595
  result: {
560
596
  performed: true,
@@ -566,8 +602,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
566
602
  };
567
603
  }
568
604
  catch {
569
- if (session.versionSkew !== undefined)
605
+ if (session.versionSkew !== undefined) {
570
606
  throw new Error(session.versionSkew);
607
+ }
571
608
  return {
572
609
  result: undefined,
573
610
  settled: false,
@@ -585,8 +622,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
585
622
  * A Proxy, not a copy: the session keeps private state its own methods read.
586
623
  */
587
624
  export function withLeaseKeyboard(deps, session) {
588
- if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true)
625
+ if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true) {
589
626
  return session;
627
+ }
590
628
  const command = async (name, args = {}, timeoutMs) => {
591
629
  if (ReticleCommand.ACT === name && ActionType.PRESS === args['action']) {
592
630
  const ref = asString(args['ref']) ?? '';
@@ -600,8 +638,9 @@ export function withLeaseKeyboard(deps, session) {
600
638
  };
601
639
  return new Proxy(session, {
602
640
  get(target, key) {
603
- if ('command' === key)
641
+ if ('command' === key) {
604
642
  return command;
643
+ }
605
644
  const value = Reflect.get(target, key, target);
606
645
  return 'function' === typeof value
607
646
  ? value.bind(target)
@@ -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
+ }