@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
@@ -10,25 +10,21 @@
10
10
  * pay for it; the type-only import is elided by `tsc`, so the build stays green without it.
11
11
  */
12
12
  import { takeJsCoverage } from './js-coverage.js';
13
+ import { DriveError } from './drive-error.js';
14
+ import { pressViaKeyboard } from './real-input-press.js';
13
15
  import { stampedDriveUrl } from './drive-url-stamp.js';
14
16
  import { launchChromium } from '../../launch-chromium.js';
17
+ import { pageViewportOptions } from '../../chromium-launch-options.js';
15
18
  import { chromiumLaunchHint, gotoOptions, SCREENSHOT_DETERMINISM, captureWithChromeHidden, } from '../pool/playwright-launcher.js';
16
19
  import { BrowserLaunchKind } from '@reticlehq/core/telemetry';
17
20
  import { getSessionMetrics } from '../../telemetry/session-metrics.js';
18
21
  import { classifyConnectFailure } from '../../telemetry/connect-failure.js';
19
- import { ActionType, clampHoldMs, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG, explicitCodeFromArgs, InputMode, InputModeReason, pressKeyFromArgs, pressKeysFromArgs, pressModifiersFromArgs, } from '@reticlehq/core';
22
+ import { ActionType, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG, ClickButton, clickButtonFromArgs, InputMode, pressModifiersFromArgs, RETICLE_CHROME_PASS_THROUGH_SCRIPT, RETICLE_CHROME_RESTORE_POINTER_SCRIPT, } from '@reticlehq/core';
20
23
  import { installNetworkMocks } from './network-mock.js';
21
24
  import { attachNetworkDetail } from './network-detail.js';
22
25
  import { injectedConnectArgs } from '../pool/zero-install.js';
23
- /** Structured, code-tagged failure so callers branch on cause, not message text. */
24
- export class DriveError extends Error {
25
- code;
26
- constructor(code, message) {
27
- super(message);
28
- this.code = code;
29
- this.name = 'DriveError';
30
- }
31
- }
26
+ export { DriveError };
27
+ export { unspellablePressReason } from './real-input-press.js';
32
28
  /** Center of a viewport box in CSS px. Pure — unit-tested directly. */
33
29
  export function boxCenter(box) {
34
30
  return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 };
@@ -63,149 +59,12 @@ function undriven(action, box) {
63
59
  const REAL_INPUT_SETTLE_MS = 16;
64
60
  /** Default number of interpolation steps for a native drag. */
65
61
  const DEFAULT_DRAG_STEPS = 8;
66
- /**
67
- * Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
68
- * Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
69
- * cannot read the same args differently.
70
- *
71
- * A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
72
- * `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
73
- * The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
74
- * keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
75
- * — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
76
- * nothing now has a reason to try it.
77
- */
78
- async function pressViaKeyboard(page, args, sleep, now) {
79
- const key = pressKeyFromArgs(args);
80
- const modifiers = pressModifiersFromArgs(args);
81
- const chord = 0 < modifiers.length ? `${modifiers.join('+')}+${key}` : key;
82
- const holdMs = clampHoldMs(args.holdMs);
83
- if (0 === holdMs) {
84
- // `press` DOES take a chord string ("Control+k"), so the tap path needs no splitting.
85
- await page.keyboard.press(chord);
86
- return 0;
87
- }
88
- /*
89
- * `down`/`up` take ONE key each, unlike `press`: `down('Control+k')` throws
90
- * `Unknown key: "Control+k"` — measured on a real Chromium, not assumed. The chord string the
91
- * tap path uses therefore cannot express a hold, so the chord is split here: modifiers down in
92
- * chord order, the key last, and released in reverse (key first, modifiers reversed), which is
93
- * what a hand does and what an app's keyup bookkeeping expects.
94
- *
95
- * Without the split a held shortcut threw before a single key moved, the caller fell back to
96
- * the synthetic path, and a real hold was impossible for any press with modifiers — the exact
97
- * gesture `holdMs` exists for.
98
- *
99
- * ## Why every key that went down is tracked, and every exit path cleans up
100
- *
101
- * The down calls are the ones that MOVE the keyboard, and they can fail partway: `Control` goes
102
- * down, the key after it rejects, and the driver's copy of `Control` is still held. That path
103
- * used to fall straight to the caller's synthetic replay — which presses the same chord again on
104
- * a keyboard that is already holding a modifier. Tracking what actually went down is what makes
105
- * the cleanup below able to release the right keys, and only those: releasing a key that never
106
- * went down is its own lie, and it would turn a clean failure into an unnecessary refusal.
107
- */
108
- const pressed = [];
109
- const pressDown = async (k) => {
110
- await page.keyboard.down(k);
111
- pressed.push(k);
112
- };
113
- try {
114
- for (const modifier of modifiers)
115
- await pressDown(modifier);
116
- await pressDown(key);
117
- }
118
- catch (error) {
119
- // The chord is PARTIALLY down. Release what is held before reporting the failure; if that
120
- // cannot be proved, `releaseKeys` throws RELEASE_FAILED and the caller refuses the replay —
121
- // the same rule as a failed release on the ordinary path, for the same reason. Only when the
122
- // cleanup DOES succeed is the original error safe to hand back.
123
- await releaseKeys(page, pressed);
124
- throw error;
62
+ /** The mouse options a click needs: the button, only when it is not the default left one. */
63
+ function clickOptionsOf(args) {
64
+ const button = clickButtonFromArgs({ button: args.button });
65
+ if (button === ClickButton.RIGHT || button === ClickButton.MIDDLE) {
66
+ return { button };
125
67
  }
126
- const startedAt = now();
127
- try {
128
- await sleep(holdMs);
129
- }
130
- catch (error) {
131
- // The wait failed, and the caller is about to run the synthetic path — which presses this SAME
132
- // key a second time. If the driver's copy stays down, a held modifier then colours every later
133
- // action for the rest of the run. Best-effort on purpose: whatever went wrong with the wait is
134
- // the error worth reporting. A cleanup that cannot be proved is the exception — that one is a
135
- // stuck key, and it must reach the caller as a refusal rather than be swallowed.
136
- try {
137
- await releaseKeys(page, pressed);
138
- }
139
- catch {
140
- throw new DriveError(DriveErrorCode.RELEASE_FAILED, 'the wait failed and the keys it pressed could not be released, so they may still be held');
141
- }
142
- throw error;
143
- }
144
- // NOT best-effort: on the ordinary path a failed release is a real failure the caller must hear
145
- // about, and swallowing it would report a press whose key is still down.
146
- await releaseKeys(page, pressed);
147
- return now() - startedAt;
148
- }
149
- /**
150
- * Release the given keys, in reverse press order, and report a failure as one that leaves the
151
- * keyboard UNKNOWN rather than as an ordinary provider error.
152
- *
153
- * Every release is attempted even after one fails: a stuck `Control` is worse than a stuck letter,
154
- * so a failure on one key must not skip the rest. The first failure is what is thrown, since it is
155
- * the one that happened first, and it is wrapped in `DriveError` so the caller can tell "this
156
- * gesture failed" from "this gesture may have left a key down" — the difference between a safe
157
- * synthetic replay and an unsafe one.
158
- */
159
- async function releaseKeys(page, pressed) {
160
- let failure;
161
- for (const k of [...pressed].reverse()) {
162
- try {
163
- await page.keyboard.up(k);
164
- }
165
- catch (error) {
166
- failure ??= error;
167
- }
168
- }
169
- if (failure !== undefined) {
170
- throw new DriveError(DriveErrorCode.RELEASE_FAILED, `the key was pressed but could not be released, so it may still be held: ${describeFailure(failure)}`);
171
- }
172
- }
173
- /**
174
- * A thrown value as a line a reader can act on.
175
- *
176
- * Not `String(error)`: a non-Error thrown value (a rejected plain object, an SDK that throws a
177
- * record) stringifies to `[object Object]`, which names nothing and hides the one fact the message
178
- * exists to carry. Anything that is neither an Error nor a string says so rather than pretending.
179
- */
180
- function describeFailure(error) {
181
- if (error instanceof Error)
182
- return error.message;
183
- return 'string' === typeof error ? error : 'an unknown error';
184
- }
185
- /**
186
- * The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
187
- *
188
- * `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
189
- * That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
190
- * — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
191
- * presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
192
- * `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
193
- * routed to the synthetic path by the caller, which reports the reason rather than pretending a
194
- * different gesture happened.
195
- *
196
- * Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
197
- * the target. Two can apply at once, and the earlier one is the more unusual request — the reason
198
- * should name that, not the generic "you passed a ref".
199
- */
200
- export function unspellablePressReason(ref, args) {
201
- if (0 < pressKeysFromArgs(args).length) {
202
- return InputModeReason.SYNTHETIC_MULTI_KEY_PRESS_PREFERRED;
203
- }
204
- if (explicitCodeFromArgs(args) !== undefined) {
205
- return InputModeReason.SYNTHETIC_KEY_CODE_PRESS_PREFERRED;
206
- }
207
- if (0 < ref.length)
208
- return InputModeReason.SYNTHETIC_ELEMENT_PRESS_PREFERRED;
209
68
  return undefined;
210
69
  }
211
70
  /**
@@ -227,6 +86,27 @@ now = Date.now) {
227
86
  ...(heldMs > 0 ? { heldMs } : {}),
228
87
  };
229
88
  }
89
+ return withChromePassThrough(page, () => pointerGesture(page, action, box, args, sleep));
90
+ }
91
+ /**
92
+ * Run a real pointer gesture with Reticle's own UI out of the hit test, and put it back after.
93
+ *
94
+ * A real pointer goes to the topmost element under it. When the HUD sat over the app's control, a
95
+ * driven click went to the HUD and the verdict said the app did nothing; a drag ended short (#992).
96
+ * The restore runs whatever the gesture did, so the HUD does not stay unclickable for a person.
97
+ */
98
+ export async function withChromePassThrough(page, gesture) {
99
+ await page.evaluate(RETICLE_CHROME_PASS_THROUGH_SCRIPT);
100
+ try {
101
+ return await gesture();
102
+ }
103
+ finally {
104
+ // A click that navigated takes the old page and its held values with it.
105
+ await page.evaluate(RETICLE_CHROME_RESTORE_POINTER_SCRIPT).catch(() => undefined);
106
+ }
107
+ }
108
+ /** The pointer half of `performGesture`: every action that lands at a point. */
109
+ async function pointerGesture(page, action, box, args, sleep) {
230
110
  const center = boxCenter(box);
231
111
  const { cx, cy } = center;
232
112
  const real = (performed) => ({
@@ -241,19 +121,32 @@ now = Date.now) {
241
121
  await sleep(REAL_INPUT_SETTLE_MS);
242
122
  return real(true);
243
123
  }
244
- if (action === ActionType.CLICK) {
124
+ if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
245
125
  await page.mouse.move(cx, cy);
246
- await page.mouse.click(cx, cy);
247
- return real(true);
248
- }
249
- if (action === ActionType.DBLCLICK) {
250
- await page.mouse.move(cx, cy);
251
- await page.mouse.dblclick(cx, cy);
126
+ const options = clickOptionsOf(args);
127
+ const modifiers = pressModifiersFromArgs(args);
128
+ for (const modifier of modifiers) {
129
+ await page.keyboard.down(modifier);
130
+ }
131
+ try {
132
+ if (action === ActionType.CLICK) {
133
+ await page.mouse.click(cx, cy, options);
134
+ }
135
+ else {
136
+ await page.mouse.dblclick(cx, cy, options);
137
+ }
138
+ }
139
+ finally {
140
+ for (const modifier of [...modifiers].reverse()) {
141
+ await page.keyboard.up(modifier);
142
+ }
143
+ }
252
144
  return real(true);
253
145
  }
254
146
  if (action === ActionType.DRAG) {
255
- if (args.toBox === undefined)
147
+ if (args.toBox === undefined) {
256
148
  return real(false);
149
+ }
257
150
  const dst = boxCenter(args.toBox);
258
151
  const steps = args.steps ?? DEFAULT_DRAG_STEPS;
259
152
  await page.mouse.move(cx, cy);
@@ -341,8 +234,9 @@ export class CdpRealInputProvider {
341
234
  /** Attach the response listener the first time we see a page; a no-op afterwards. */
342
235
  #listen(page) {
343
236
  const sink = this.#onNetworkDetail;
344
- if (sink === undefined || this.#listening.has(page))
237
+ if (sink === undefined || this.#listening.has(page)) {
345
238
  return;
239
+ }
346
240
  this.#listening.add(page);
347
241
  attachNetworkDetail(page, sink);
348
242
  }
@@ -354,8 +248,9 @@ export class CdpRealInputProvider {
354
248
  // Playwright's Browser exposes isConnected(); a test fake may not, so only an EXPLICIT false
355
249
  // drops the cache — a fake without the method is assumed live.
356
250
  const alive = this.#browser.isConnected?.() ?? true;
357
- if (alive)
251
+ if (alive) {
358
252
  return this.#browser;
253
+ }
359
254
  this.#browser = undefined; // fall through and reconnect
360
255
  }
361
256
  try {
@@ -369,11 +264,13 @@ export class CdpRealInputProvider {
369
264
  }
370
265
  async #pageFor(sessionUrl) {
371
266
  const browser = await this.#ensureBrowser();
372
- if (browser === undefined)
267
+ if (browser === undefined) {
373
268
  return undefined;
269
+ }
374
270
  const page = selectPage(browser.contexts().flatMap((c) => c.pages()), sessionUrl);
375
- if (page !== undefined)
271
+ if (page !== undefined) {
376
272
  this.#listen(page);
273
+ }
377
274
  return page;
378
275
  }
379
276
  async takeCodeCoverage(sessionUrl) {
@@ -390,22 +287,25 @@ export class CdpRealInputProvider {
390
287
  }
391
288
  async perform(sessionUrl, action, box, args) {
392
289
  const page = await this.#pageFor(sessionUrl);
393
- if (page === undefined)
290
+ if (page === undefined) {
394
291
  return undriven(action, box);
292
+ }
395
293
  return performGesture(page, action, box, args, this.#sleep, this.#now);
396
294
  }
397
295
  /** PNG of the correlated page, or undefined if none matches. */
398
296
  async screenshot(sessionUrl, opts) {
399
297
  const page = await this.#pageFor(sessionUrl);
400
- if (page === undefined)
298
+ if (page === undefined) {
401
299
  return undefined;
300
+ }
402
301
  return capturePage(page, opts);
403
302
  }
404
303
  /** The context's cookies and per-origin storage; undefined when no driven page matches. */
405
304
  async storageState(sessionUrl) {
406
305
  const page = await this.#pageFor(sessionUrl);
407
- if (page === undefined)
306
+ if (page === undefined) {
408
307
  return undefined;
308
+ }
409
309
  return page.context().storageState();
410
310
  }
411
311
  /**
@@ -416,27 +316,32 @@ export class CdpRealInputProvider {
416
316
  */
417
317
  async applyStorageState(sessionUrl, state) {
418
318
  const page = await this.#pageFor(sessionUrl);
419
- if (page === undefined)
319
+ if (page === undefined) {
420
320
  return false;
321
+ }
421
322
  const saved = state;
422
- if (saved.cookies !== undefined)
323
+ if (saved.cookies !== undefined) {
423
324
  await page.context().addCookies(saved.cookies);
325
+ }
424
326
  for (const origin of saved.origins ?? []) {
425
327
  // Only the document for an origin can write that origin's localStorage, so this is applied in
426
328
  // the page rather than through the context. A page sitting on a different origin cannot be
427
329
  // given another origin's storage, and silently succeeding there would be the quiet half-restore
428
330
  // this whole path exists to avoid.
429
- if (!page.url().startsWith(origin.origin))
331
+ if (!page.url().startsWith(origin.origin)) {
430
332
  continue;
333
+ }
431
334
  await page.evaluate((entries) => {
432
335
  // Reached through `globalThis` rather than `window`: this body is serialised and runs in the
433
336
  // PAGE, but it is typechecked here, and this package never pulls in the DOM lib — a server
434
337
  // that can name `window` is one step from a server that uses it.
435
338
  const store = globalThis.localStorage;
436
- if (store === undefined)
339
+ if (store === undefined) {
437
340
  return;
438
- for (const entry of entries)
341
+ }
342
+ for (const entry of entries) {
439
343
  store.setItem(entry.name, entry.value);
344
+ }
440
345
  }, origin.localStorage ?? []);
441
346
  }
442
347
  return true;
@@ -444,16 +349,18 @@ export class CdpRealInputProvider {
444
349
  /** Apply network-mock rules to the correlated page; false when no driven page matches. */
445
350
  async setMocks(sessionUrl, rules) {
446
351
  const page = await this.#pageFor(sessionUrl);
447
- if (page === undefined)
352
+ if (page === undefined) {
448
353
  return false;
354
+ }
449
355
  await installNetworkMocks(page, rules);
450
356
  return true;
451
357
  }
452
358
  /** Pin the correlated page's viewport to fixed dimensions; false when no driven page matches. */
453
359
  async setViewport(sessionUrl, size) {
454
360
  const page = await this.#pageFor(sessionUrl);
455
- if (page === undefined)
361
+ if (page === undefined) {
456
362
  return false;
363
+ }
457
364
  await page.setViewportSize({ width: size.width, height: size.height });
458
365
  return true;
459
366
  }
@@ -461,11 +368,14 @@ export class CdpRealInputProvider {
461
368
  async dispose() {
462
369
  const browser = this.#browser;
463
370
  this.#browser = undefined;
464
- if (browser !== undefined)
371
+ if (browser !== undefined) {
465
372
  await browser.close();
373
+ }
466
374
  }
467
375
  }
468
376
  const INJECT_CONNECT_WAIT_MS = 8_000;
377
+ /** True on a page whose Reticle SDK has loaded. */
378
+ const SDK_PRESENT = '!!globalThis.__reticleInstance';
469
379
  /** The only place the dynamic value import of Playwright lives for the launched (drive) path. */
470
380
  export const launchedChromium = async (headless) => {
471
381
  let mod;
@@ -500,6 +410,7 @@ export class LaunchedRealInputProvider {
500
410
  #injectConnect;
501
411
  #storageState;
502
412
  #onNetworkDetail;
413
+ #zeroInstallScript;
503
414
  #browser;
504
415
  #page;
505
416
  /** Pages collecting V8 coverage — see js-coverage.ts. */
@@ -513,14 +424,19 @@ export class LaunchedRealInputProvider {
513
424
  this.#injectConnect = options.injectConnect;
514
425
  this.#storageState = options.storageState;
515
426
  this.#onNetworkDetail = options.onNetworkDetail;
427
+ this.#zeroInstallScript = options.zeroInstallScript;
516
428
  }
517
429
  async navigate() {
518
430
  this.#browser = await this.#launch(this.#headless);
519
- const page = await this.#browser.newPage(this.#storageState !== undefined ? { storageState: this.#storageState } : undefined);
431
+ const page = await this.#browser.newPage({
432
+ ...pageViewportOptions(this.#headless),
433
+ ...(this.#storageState !== undefined ? { storageState: this.#storageState } : {}),
434
+ });
520
435
  this.#page = page;
521
436
  // Capture CDP-authoritative response detail into the driven session's journal (best-effort).
522
- if (this.#onNetworkDetail !== undefined)
437
+ if (this.#onNetworkDetail !== undefined) {
523
438
  attachNetworkDetail(page, this.#onNetworkDetail);
439
+ }
524
440
  try {
525
441
  // Same navigation rule as the pool, and for the same measured reason: Playwright's default
526
442
  // waits for `load`, which an app with one never-finishing subresource never fires — 30s of
@@ -533,8 +449,35 @@ export class LaunchedRealInputProvider {
533
449
  catch (e) {
534
450
  throw new DriveError(DriveErrorCode.NAVIGATE_FAILED, e instanceof Error ? e.message : String(e));
535
451
  }
452
+ await this.#supplyReader(page);
536
453
  await this.#tryInjectConnect(page);
537
454
  }
455
+ /**
456
+ * Give a page with no Reticle SDK the zero-install reader, so `verify` on an app with no `init`
457
+ * still gets a session. The wait for the page's own SDK comes first, as on a leased tab, and the
458
+ * script checks the singleton, so a page with an SDK never gets a second one.
459
+ */
460
+ async #supplyReader(page) {
461
+ const script = this.#zeroInstallScript?.();
462
+ if (script === undefined) {
463
+ return;
464
+ }
465
+ try {
466
+ await page.waitForFunction(SDK_PRESENT, { timeout: INJECT_CONNECT_WAIT_MS });
467
+ return;
468
+ }
469
+ catch {
470
+ // No SDK on the page: supply the reader below.
471
+ }
472
+ try {
473
+ // Also on every later document, so a full navigation keeps the session.
474
+ await page.addInitScript(script);
475
+ await page.evaluate(script);
476
+ }
477
+ catch {
478
+ // A page that refuses the reader has no session. verify's no-session guard reports it.
479
+ }
480
+ }
538
481
  /**
539
482
  * Wait for the page's Reticle singleton to exist, then re-invoke connect with our token + loopback
540
483
  * URL so a hosted (non-localhost) preview pairs to our bridge without the app being reconfigured.
@@ -542,10 +485,11 @@ export class LaunchedRealInputProvider {
542
485
  */
543
486
  async #tryInjectConnect(page) {
544
487
  const opts = this.#injectConnect;
545
- if (opts === undefined)
488
+ if (opts === undefined) {
546
489
  return;
490
+ }
547
491
  try {
548
- await page.waitForFunction('!!globalThis.__reticleInstance', {
492
+ await page.waitForFunction(SDK_PRESENT, {
549
493
  timeout: INJECT_CONNECT_WAIT_MS,
550
494
  });
551
495
  const arg = JSON.stringify(injectedConnectArgs(opts));
@@ -567,8 +511,9 @@ export class LaunchedRealInputProvider {
567
511
  */
568
512
  #livePage() {
569
513
  const page = this.#page;
570
- if (page === undefined)
514
+ if (page === undefined) {
571
515
  return undefined;
516
+ }
572
517
  if (true === page.isClosed?.()) {
573
518
  this.#page = undefined; // never ask a corpse twice
574
519
  return undefined;
@@ -577,10 +522,12 @@ export class LaunchedRealInputProvider {
577
522
  }
578
523
  isAvailableFor(sessionUrl) {
579
524
  const page = this.#livePage();
580
- if (page === undefined)
525
+ if (page === undefined) {
581
526
  return Promise.resolve(false);
582
- if (page.url() === sessionUrl)
527
+ }
528
+ if (page.url() === sessionUrl) {
583
529
  return Promise.resolve(true);
530
+ }
584
531
  return Promise.resolve(stripVolatile(page.url()) === stripVolatile(sessionUrl));
585
532
  }
586
533
  /**
@@ -593,8 +540,9 @@ export class LaunchedRealInputProvider {
593
540
  */
594
541
  async storageState(_sessionUrl) {
595
542
  const page = this.#livePage();
596
- if (page === undefined)
543
+ if (page === undefined) {
597
544
  return undefined;
545
+ }
598
546
  return page.context().storageState();
599
547
  }
600
548
  /**
@@ -603,37 +551,44 @@ export class LaunchedRealInputProvider {
603
551
  */
604
552
  async applyStorageState(_sessionUrl, state) {
605
553
  const page = this.#livePage();
606
- if (page === undefined)
554
+ if (page === undefined) {
607
555
  return false;
556
+ }
608
557
  const saved = state;
609
- if (saved.cookies !== undefined)
558
+ if (saved.cookies !== undefined) {
610
559
  await page.context().addCookies(saved.cookies);
560
+ }
611
561
  for (const origin of saved.origins ?? []) {
612
562
  // A page sitting on a different origin cannot be given another origin's storage, and silently
613
563
  // succeeding there is the quiet half-restore this whole path exists to avoid.
614
- if (!page.url().startsWith(origin.origin))
564
+ if (!page.url().startsWith(origin.origin)) {
615
565
  continue;
566
+ }
616
567
  await page.evaluate((entries) => {
617
568
  const store = globalThis.localStorage;
618
- if (store === undefined)
569
+ if (store === undefined) {
619
570
  return;
620
- for (const entry of entries)
571
+ }
572
+ for (const entry of entries) {
621
573
  store.setItem(entry.name, entry.value);
574
+ }
622
575
  }, origin.localStorage ?? []);
623
576
  }
624
577
  return true;
625
578
  }
626
579
  perform(_sessionUrl, action, box, args) {
627
580
  const page = this.#livePage();
628
- if (page === undefined)
581
+ if (page === undefined) {
629
582
  return Promise.resolve(undriven(action, box));
583
+ }
630
584
  return performGesture(page, action, box, args, this.#sleep, this.#now);
631
585
  }
632
586
  /** PNG of the owned page, or undefined before navigate / after dispose. */
633
587
  screenshot(_sessionUrl, opts) {
634
588
  const page = this.#livePage();
635
- if (page === undefined)
589
+ if (page === undefined) {
636
590
  return Promise.resolve(undefined);
591
+ }
637
592
  return capturePage(page, opts);
638
593
  }
639
594
  /**
@@ -646,8 +601,9 @@ export class LaunchedRealInputProvider {
646
601
  */
647
602
  async setViewport(_sessionUrl, size) {
648
603
  const page = this.#livePage();
649
- if (page === undefined)
604
+ if (page === undefined) {
650
605
  return false;
606
+ }
651
607
  await page.setViewportSize({ width: size.width, height: size.height });
652
608
  return true;
653
609
  }
@@ -658,8 +614,9 @@ export class LaunchedRealInputProvider {
658
614
  /** Apply network-mock rules to the owned page; false before navigate / after dispose. */
659
615
  async setMocks(_sessionUrl, rules) {
660
616
  const page = this.#livePage();
661
- if (page === undefined)
617
+ if (page === undefined) {
662
618
  return false;
619
+ }
663
620
  await installNetworkMocks(page, rules);
664
621
  return true;
665
622
  }
@@ -668,8 +625,9 @@ export class LaunchedRealInputProvider {
668
625
  const browser = this.#browser;
669
626
  this.#browser = undefined;
670
627
  this.#page = undefined;
671
- if (browser !== undefined)
628
+ if (browser !== undefined) {
672
629
  await browser.close();
630
+ }
673
631
  }
674
632
  }
675
633
  /** Drop hash/query so a page whose URL drifted by fragment still correlates to the session. */
@@ -688,8 +646,9 @@ export class LaunchedRealInputProvider {
688
646
  */
689
647
  export function selectPage(pages, sessionUrl) {
690
648
  const exact = pages.find((p) => p.url() === sessionUrl);
691
- if (exact !== undefined)
649
+ if (exact !== undefined) {
692
650
  return exact;
651
+ }
693
652
  const target = stripVolatile(sessionUrl);
694
653
  const loose = pages.filter((p) => stripVolatile(p.url()) === target);
695
654
  return 1 === loose.length ? loose[0] : undefined;
@@ -83,8 +83,8 @@ export const VIEWPORT_TOOLS = [
83
83
  },
84
84
  outputSchema: {
85
85
  applied: z.boolean(),
86
- width: z.number(),
87
- height: z.number(),
86
+ width: z.number().optional(),
87
+ height: z.number().optional(),
88
88
  observed: z.object({ width: z.number(), height: z.number() }).optional(),
89
89
  colorScheme: z.string().optional(),
90
90
  reducedMotion: z.string().optional(),
@@ -97,7 +97,10 @@ export const VIEWPORT_TOOLS = [
97
97
  if (media !== undefined && args['width'] === undefined && args['height'] === undefined) {
98
98
  const session = deps.sessions.resolve(asString(args['sessionId']));
99
99
  if (true === (await deps.pool?.emulateLease(session.id, media))) {
100
- return { applied: true, width: 0, height: 0, ...media };
100
+ // No size was asked for, so answer the one the lease has; 0 by 0 reads as a broken
101
+ // viewport (#1902). When the page cannot be read, leave the size out.
102
+ const size = await deps.pool?.viewportOfLease(session.id);
103
+ return { applied: true, ...size, ...media };
101
104
  }
102
105
  return { ...refused(), recommendation: MEDIA_NEEDS_A_LEASE };
103
106
  }
@@ -13,7 +13,7 @@ import { type SeedStorage } from '@reticlehq/core';
13
13
  import type { Launcher, PooledMockRule, ScreenshotOptions } from './pool-contract.js';
14
14
  import type { RealInputArgs, RealInputResult } from '../input/real-input.js';
15
15
  import { type LeaseEmulation, type LeaseMedia } from './lease-emulation.js';
16
- export type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledCookie, PooledDialog, PooledMockRule, PooledPage, } from './pool-contract.js';
16
+ export type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledCookie, PooledDialog, PooledMockRule, PooledPage, PooledPageError, } from './pool-contract.js';
17
17
  import { type BrowserPoolOptions, type Lease } from './pool-lease.js';
18
18
  export { DEFAULT_LEASE_TTL_MS, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, PoolAtCapacityError, } from './pool-lease.js';
19
19
  export type { Lease } from './pool-lease.js';
@@ -101,6 +101,8 @@ export declare class BrowserPool {
101
101
  * reaper take it out from under a diff.
102
102
  */
103
103
  screenshotLease(sessionId: string, opts?: ScreenshotOptions): Promise<Uint8Array | undefined>;
104
+ /** The reason {@link screenshotLease} last failed for this session, if it did. */
105
+ leaseCaptureError(sessionId: string): string | undefined;
104
106
  /** The URL a lease was opened on — the one that carries its Reticle identity. */
105
107
  leaseUrl(sessionId: string): string | undefined;
106
108
  /**
@@ -243,6 +245,8 @@ export declare class BrowserPool {
243
245
  lastDialogMessage(sessionId: string): string | undefined;
244
246
  /** Close every context and the browser. Pending waiters are rejected (the pool is terminal now). */
245
247
  shutdown(): Promise<void>;
248
+ /** The origin added to this lease's page CSP `connect-src`, or undefined if none was (#2016). */
249
+ cspWidenedFor(sessionId: string): string | undefined;
246
250
  /** Whether this lease's context skips certificate checks. A context cannot change it later. */
247
251
  ignoresHTTPSErrors(sessionId: string): boolean;
248
252
  /** Whether this lease lives in the headed browser. */