@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
@@ -1,5 +1,5 @@
1
1
  import { SECRET_FIELD_ARG, SECRET_NAME_ARG, secretKeyForFill, secretValue, } from './fields/flow-secret-field.js';
2
- import { EventType, FlowPredicateSchema, MUTATING_METHODS, Verified, isDevToolingUrl, pathOf, } from '@reticlehq/core';
2
+ import { EventType, FlowPredicateSchema, MUTATING_METHODS, isDevToolingUrl, pathOf, } from '@reticlehq/core';
3
3
  import { DANGEROUS_ACTION_CONFIRM_ARG, DOCUMENT_ANCHOR_ROLE, ReticleCommand, QueryBy, isGlobalPressCall, } from '@reticlehq/core';
4
4
  import { ReticleTool, invokedFlowMissing } from '@reticlehq/core';
5
5
  import { formatStepAddress } from 'open-verification';
@@ -22,8 +22,9 @@ function ambiguousRoleNote(role, name) {
22
22
  * resolution — can't drift between them.
23
23
  */
24
24
  export function queryRefs(result) {
25
- if (!result.ok)
25
+ if (!result.ok) {
26
26
  return [];
27
+ }
27
28
  const payload = asRecord(result.result);
28
29
  const elements = Array.isArray(payload['elements']) ? payload['elements'] : [];
29
30
  return elements.map((e) => asString(asRecord(e)['ref']) ?? '').filter((r) => r.length > 0);
@@ -47,12 +48,14 @@ export function replayActionArgs(value, confirmDangerous = false, field) {
47
48
  const key = field === undefined ? undefined : secretKeyForFill(args, field);
48
49
  delete args[SECRET_NAME_ARG];
49
50
  delete args[SECRET_FIELD_ARG];
50
- if (confirmDangerous)
51
+ if (confirmDangerous) {
51
52
  args[DANGEROUS_ACTION_CONFIRM_ARG] = true;
53
+ }
52
54
  if (key !== undefined) {
53
55
  const supplied = secretValue(key, process.env);
54
- if (supplied !== undefined && supplied.length > 0)
56
+ if (supplied !== undefined && supplied.length > 0) {
55
57
  args['value'] = supplied;
58
+ }
56
59
  }
57
60
  return args;
58
61
  }
@@ -62,20 +65,24 @@ export function replayActionArgs(value, confirmDangerous = false, field) {
62
65
  */
63
66
  function recordedFieldName(step) {
64
67
  const by = asString(step['by']);
65
- if (QueryBy.TESTID === by)
68
+ if (QueryBy.TESTID === by) {
66
69
  return asString(step['value']);
67
- if (QueryBy.ROLE === by)
70
+ }
71
+ if (QueryBy.ROLE === by) {
68
72
  return asString(step['name']);
73
+ }
69
74
  return undefined;
70
75
  }
71
76
  /** The element's source location from an action result, when the framework stamped one. */
72
77
  function sourceFromResult(res) {
73
78
  const source = asRecord(res['source']);
74
- if (typeof source['file'] !== 'string' || typeof source['line'] !== 'number')
79
+ if (typeof source['file'] !== 'string' || typeof source['line'] !== 'number') {
75
80
  return undefined;
81
+ }
76
82
  const out = { file: source['file'], line: source['line'] };
77
- if ('number' === typeof source['column'])
83
+ if ('number' === typeof source['column']) {
78
84
  out['column'] = source['column'];
85
+ }
79
86
  return out;
80
87
  }
81
88
  /**
@@ -91,8 +98,9 @@ export { pathOf };
91
98
  */
92
99
  export function wroteIn(events) {
93
100
  return events.some((event) => {
94
- if (EventType.NET_REQUEST !== event.type)
101
+ if (EventType.NET_REQUEST !== event.type) {
95
102
  return false;
103
+ }
96
104
  const method = asString(event.data['method'])?.toUpperCase() ?? '';
97
105
  return MUTATING_METHODS.includes(method) && !isDevToolingUrl(asString(event.data['url']));
98
106
  });
@@ -122,7 +130,9 @@ route,
122
130
  /** The session that acted: only recordings it started take the step. */
123
131
  session,
124
132
  /** The act's window sent a write request; see `wroteIn`. */
125
- wrote = false) {
133
+ wrote = false,
134
+ /** The act as the ambient tape keeps it, when that differs. See RecordingStore.capture. */
135
+ ambientArgs) {
126
136
  // NO `active()` gate. `RecordingStore.capture` opens the AMBIENT tape on its first step, and that
127
137
  // branch was unreachable from here: this returned early whenever nothing was open, and the only
128
138
  // thing that opened anything was `reticle_record { action: "start" }`. So the comment on `capture`
@@ -134,8 +144,15 @@ wrote = false) {
134
144
  // recorded cannot contribute to either.
135
145
  // A click on Reticle's own panel (the HUD exposed) is not a step of the app's journey: saved, the
136
146
  // app's flow pressed Run Harness on replay, a paid drive, under an intent that never named it.
137
- if (true === asRecord(res)['reticleUi'])
147
+ if (true === asRecord(res)['reticleUi']) {
138
148
  return;
149
+ }
150
+ const step = capturedStep(args, res, route, wrote);
151
+ const ambient = ambientArgs === undefined ? undefined : capturedStep(ambientArgs, res, route, wrote);
152
+ recordings.capture(step, route, session, ambient);
153
+ }
154
+ /** One act as a recorded step, with the claim the agent declared on it. */
155
+ function capturedStep(args, res, route, wrote) {
139
156
  const step = compileActStep(args, res);
140
157
  // Keep the assertion the agent actually made. `act_and_wait { until }` IS the agent saying what
141
158
  // success means — the large majority of calls carry one — and dropping it produced a flow graded
@@ -143,36 +160,50 @@ wrote = false) {
143
160
  // story failing at its last step. See enforceableExpect below.
144
161
  const declared = args['until'] ?? args['predicate'];
145
162
  const expect = enforceableExpect(declared);
146
- if (expect !== undefined)
163
+ if (expect !== undefined) {
147
164
  step.expect = expect;
148
- else if (declared !== undefined)
165
+ }
166
+ else if (declared !== undefined) {
149
167
  step.unkeptExpect = UNKEPT_EXPECT;
150
- if (route !== undefined)
168
+ }
169
+ if (route !== undefined) {
151
170
  step.page = route;
171
+ }
152
172
  const intent = asString(args['intent'])?.trim();
153
- if (intent !== undefined && intent.length > 0)
173
+ if (intent !== undefined && intent.length > 0) {
154
174
  step.intent = intent;
155
- if (wrote)
175
+ }
176
+ if (wrote) {
156
177
  step.writes = true;
157
- recordings.capture(step, route, session);
178
+ }
179
+ return step;
158
180
  }
159
181
  /**
160
- * Capture an act_and_wait once its verdict is known, keeping `until` as the step's expectation only
161
- * on a yes. Recorded before the verdict, a step kept an `until` that came back `no` or `no-fault` —
162
- * a regression test asserting something never once observed to hold because of the action. The
163
- * action itself is always recorded: it happened, and replay needs it to reach the next step.
182
+ * Capture an act_and_wait once its verdict is known.
183
+ *
184
+ * The rule act_sequence keeps (#988). A recording somebody opened keeps every claim the agent
185
+ * declared, held or not: dropping a refuted one saved a flow that replayed green on the defect it
186
+ * had caught. The ambient tape keeps a claim only if the action made it hold (`held`), so a flow cut
187
+ * from it never replays a claim that was never once true because of the action. The action is
188
+ * always recorded: replay needs it to reach the next step.
189
+ *
190
+ * Keyed on `held`, not on `verified: yes`. A claim the action made hold under an `unknown` or a
191
+ * contradicted verdict is still what the agent proved about the page, and dropping it left the
192
+ * replay with no check at all. One function decides both tapes: a second rule that took the claim off the tape
193
+ * after the fact ran before this capture, so it took the PREVIOUS step's claim, which had passed.
164
194
  */
165
- export function captureVerdictedAct(recordings, args, res, route, verified, session,
195
+ export function captureVerdictedAct(recordings, args, res, route, held, session,
166
196
  /** The act's window sent a write request; see `wroteIn`. */
167
197
  wrote = false) {
168
198
  const { until: _until, predicate: _predicate, ...unproved } = args;
169
- captureAct(recordings, Verified.YES === verified ? args : unproved, res, route, session, wrote);
199
+ captureAct(recordings, args, res, route, session, wrote, held ? undefined : unproved);
170
200
  }
171
201
  /** Fold a PASSING standalone assertion into the step it proved. See RecordingStore.attachExpect. */
172
202
  export function captureAssertion(recordings, raw, session) {
173
203
  const expect = enforceableExpect(raw);
174
- if (expect !== undefined)
204
+ if (expect !== undefined) {
175
205
  recordings.attachExpect(expect, session);
206
+ }
176
207
  }
177
208
  /**
178
209
  * A declared predicate, as a recorded expectation — or nothing.
@@ -186,8 +217,9 @@ export function captureAssertion(recordings, raw, session) {
186
217
  * Every one of those was a claim the agent made and the file did not keep.
187
218
  */
188
219
  function enforceableExpect(raw) {
189
- if (raw === undefined)
220
+ if (raw === undefined) {
190
221
  return undefined;
222
+ }
191
223
  // The flow file's own schema, so capture keeps exactly what a load would accept — a predicate
192
224
  // holding a session ref is refused by both, rather than recorded here and rejected on read.
193
225
  const parsed = FlowPredicateSchema.safeParse(raw);
@@ -232,8 +264,9 @@ function compileAnchorArgs(r, ref, action, actArgs) {
232
264
  const compiled = anchorArgs(r, ref, action, actArgs);
233
265
  // The SDK says the target holds a secret (a password input, whatever its label). Carried beside
234
266
  // the anchor so the saved flow redacts the fill by what the field IS, not by what it is called.
235
- if (true === r['secret'])
267
+ if (true === r['secret']) {
236
268
  compiled.args[SECRET_FIELD_ARG] = true;
269
+ }
237
270
  return compiled;
238
271
  }
239
272
  function anchorArgs(r, ref, action, actArgs) {
@@ -262,10 +295,12 @@ function anchorArgs(r, ref, action, actArgs) {
262
295
  action,
263
296
  args: actArgs,
264
297
  };
265
- if (source !== undefined)
298
+ if (source !== undefined) {
266
299
  testidArgs['source'] = source;
267
- if (testidOf !== undefined)
300
+ }
301
+ if (testidOf !== undefined) {
268
302
  testidArgs['of'] = testidOf;
303
+ }
269
304
  return { args: testidArgs, stable: true };
270
305
  }
271
306
  // Role + NAME before component: a component/source anchor names the JSX site, so every row's copy
@@ -282,8 +317,9 @@ function anchorArgs(r, ref, action, actArgs) {
282
317
  action,
283
318
  args: actArgs,
284
319
  };
285
- if (source !== undefined)
320
+ if (source !== undefined) {
286
321
  roleArgs['source'] = source;
322
+ }
287
323
  if ('number' === typeof r['nth'] && 'number' === typeof r['of']) {
288
324
  roleArgs['nth'] = r['nth'];
289
325
  roleArgs['of'] = r['of'];
@@ -293,10 +329,12 @@ function anchorArgs(r, ref, action, actArgs) {
293
329
  const component = asString(r['component']);
294
330
  if (component !== undefined || source !== undefined) {
295
331
  const componentArgs = { by: QueryBy.COMPONENT, action, args: actArgs };
296
- if (component !== undefined)
332
+ if (component !== undefined) {
297
333
  componentArgs['component'] = component;
298
- if (source !== undefined)
334
+ }
335
+ if (source !== undefined) {
299
336
  componentArgs['source'] = source;
337
+ }
300
338
  return { args: componentArgs, stable: true };
301
339
  }
302
340
  return { args: { ref, action, args: actArgs }, stable: false };
@@ -314,12 +352,14 @@ export function compileSequenceStep(args, res) {
314
352
  let stable = inputSteps.length > 0;
315
353
  const subSteps = inputSteps.flatMap((raw, i) => {
316
354
  // A step on Reticle's own panel is not a step of the app's journey, as for a single act.
317
- if (true === asRecord(resolved[i])['reticleUi'])
355
+ if (true === asRecord(resolved[i])['reticleUi']) {
318
356
  return [];
357
+ }
319
358
  const step = asRecord(raw);
320
359
  const compiled = compileAnchorArgs(asRecord(resolved[i]), asString(step['ref']) ?? '', asString(step['action']) ?? '', replayActionArgs(step['args']));
321
- if (!compiled.stable)
360
+ if (!compiled.stable) {
322
361
  stable = false;
362
+ }
323
363
  // Each sub-step keeps its OWN declared consequence. A sequence records one step and replays as
324
364
  // one batch, but what each step CLAIMED is per step -- folding them together would let one
325
365
  // signal answer for every click in the journey.
@@ -346,18 +386,21 @@ export function compileSequenceStep(args, res) {
346
386
  */
347
387
  async function resolveRef(session, step) {
348
388
  // Dispatched the way it ran live: at the document, with no ref. See compileAnchorArgs.
349
- if (isDocumentPress(step))
389
+ if (isDocumentPress(step)) {
350
390
  return { ref: '' };
391
+ }
351
392
  const by = asString(step.by);
352
393
  const value = asString(step.value);
353
394
  if (by === QueryBy.TESTID && value !== undefined) {
354
395
  const result = await session.command(ReticleCommand.QUERY, { by, value });
355
- if (!result.ok)
396
+ if (!result.ok) {
356
397
  throw new Error(result.error ?? 'query failed');
398
+ }
357
399
  const refs = queryRefs(result);
358
400
  const ref = refs[0];
359
- if (ref === undefined)
401
+ if (ref === undefined) {
360
402
  throw new Error(`testid '${value}' did not resolve in current page`);
403
+ }
361
404
  return refs.length > 1 ? { ref, note: ambiguousTestidNote(value) } : { ref };
362
405
  }
363
406
  // Role + accessible NAME: the anchor that distinguishes one row's control from another's, which
@@ -366,8 +409,9 @@ async function resolveRef(session, step) {
366
409
  const name = asString(step.name);
367
410
  const query = { by, value, ...(name === undefined ? {} : { name }) };
368
411
  const result = await session.command(ReticleCommand.QUERY, query);
369
- if (!result.ok)
412
+ if (!result.ok) {
370
413
  throw new Error(result.error ?? 'query failed');
414
+ }
371
415
  const refs = queryRefs(result);
372
416
  const meant = refFor(step, refs);
373
417
  const ref = meant ?? refs[0];
@@ -385,8 +429,9 @@ async function resolveRef(session, step) {
385
429
  ...(source === undefined ? {} : { source }),
386
430
  };
387
431
  const result = await session.command(ReticleCommand.QUERY, query);
388
- if (!result.ok)
432
+ if (!result.ok) {
389
433
  throw new Error(result.error ?? 'query failed');
434
+ }
390
435
  const refs = queryRefs(result);
391
436
  const ref = refs[0];
392
437
  if (ref === undefined) {
@@ -468,8 +513,9 @@ via = []) {
468
513
  ...via,
469
514
  ]);
470
515
  results.push(...nested);
471
- if (nested.some((r) => !r.ok))
516
+ if (nested.some((r) => !r.ok)) {
472
517
  break;
518
+ }
473
519
  continue;
474
520
  }
475
521
  try {
@@ -480,8 +526,9 @@ via = []) {
480
526
  for (const raw of subs) {
481
527
  const sub = asRecord(raw);
482
528
  const { ref, note } = await resolveRef(session, sub);
483
- if (note !== undefined)
529
+ if (note !== undefined) {
484
530
  notes.push(note);
531
+ }
485
532
  liveSteps.push({
486
533
  ref,
487
534
  action: asString(sub['action']) ?? '',
@@ -502,8 +549,9 @@ via = []) {
502
549
  session.finishAction();
503
550
  }
504
551
  results.push(buildResult(step.tool, r.ok, r.error, notes));
505
- if (!r.ok)
552
+ if (!r.ok) {
506
553
  break;
554
+ }
507
555
  }
508
556
  else {
509
557
  const { ref, note } = await resolveRef(session, step.args);
@@ -520,8 +568,9 @@ via = []) {
520
568
  session.finishAction();
521
569
  }
522
570
  results.push(buildResult(step.tool, r.ok, r.error, note !== undefined ? [note] : []));
523
- if (!r.ok)
571
+ if (!r.ok) {
524
572
  break;
573
+ }
525
574
  }
526
575
  }
527
576
  catch (e) {
@@ -537,9 +586,11 @@ via = []) {
537
586
  }
538
587
  function buildResult(tool, ok, error, notes) {
539
588
  const base = { tool, ok };
540
- if (!ok)
589
+ if (!ok) {
541
590
  base.error = error ?? 'command failed';
542
- if (notes.length > 0)
591
+ }
592
+ if (notes.length > 0) {
543
593
  base.note = notes.join('; ');
594
+ }
544
595
  return base;
545
596
  }
@@ -87,6 +87,7 @@ interface ProbeMachine {
87
87
  env: NodeJS.ProcessEnv;
88
88
  exists: (path: string) => boolean;
89
89
  pathProblem: (path: string) => ChromiumPathProblem | undefined;
90
+ platform?: NodeJS.Platform;
90
91
  }
91
92
  /**
92
93
  * The lease preflight's probe: "can a launch succeed?", which now includes the Chrome/Edge fallback
@@ -48,11 +48,15 @@ const CHANNEL_PATHS = {
48
48
  },
49
49
  };
50
50
  const WINDOWS_INSTALL_ROOTS = ['LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)'];
51
+ function darwinChannelCandidates(appPath, env) {
52
+ const home = env['HOME']?.replace(/\/+$/, '');
53
+ return undefined === home || 0 === home.length ? [appPath] : [appPath, `${home}${appPath}`];
54
+ }
51
55
  /** Candidate executable paths for a channel on this platform; empty where it is not installable. */
52
56
  export function channelExecutableCandidates(channel, platform, env) {
53
57
  const paths = CHANNEL_PATHS[channel];
54
58
  if ('darwin' === platform)
55
- return [paths.darwin];
59
+ return darwinChannelCandidates(paths.darwin, env);
56
60
  if ('linux' === platform)
57
61
  return [paths.linux];
58
62
  if ('win32' === platform) {
@@ -63,9 +67,21 @@ export function channelExecutableCandidates(channel, platform, env) {
63
67
  }
64
68
  return [];
65
69
  }
70
+ function findInstalledBrowser(deps) {
71
+ for (const channel of CHANNEL_ORDER) {
72
+ const found = channelExecutableCandidates(channel, deps.platform, deps.env).find((p) => deps.exists(p));
73
+ if (undefined !== found) {
74
+ // Playwright resolves a channel from its own registry (/Applications on macOS). A browser in
75
+ // ~/Applications is not in that registry, so launch it by executablePath instead.
76
+ const outsideRegistry = 'darwin' === deps.platform && found !== CHANNEL_PATHS[channel].darwin;
77
+ return outsideRegistry ? { channel, executablePath: found } : { channel };
78
+ }
79
+ }
80
+ return undefined;
81
+ }
66
82
  /** The first installed Chrome/Edge on this machine, or undefined. Looks only; installs nothing. */
67
83
  export function findInstalledChannel(deps) {
68
- return CHANNEL_ORDER.find((channel) => channelExecutableCandidates(channel, deps.platform, deps.env).some((p) => deps.exists(p)));
84
+ return findInstalledBrowser(deps)?.channel;
69
85
  }
70
86
  const usingChannelLine = (channel) => `[reticle] Playwright's Chromium is not installed; using the installed ${CHANNEL_LABEL[channel]} instead.\n`;
71
87
  const installingLine = (command) => `[reticle] No Chromium, Chrome or Edge found; installing Playwright's Chromium (${command}) — a one-time download…\n`;
@@ -98,10 +114,12 @@ export async function resolveChromiumTarget(deps) {
98
114
  }
99
115
  if (deps.exists(bundled))
100
116
  return { found: true };
101
- const channel = findInstalledChannel(deps);
102
- if (channel !== undefined) {
103
- deps.announce(usingChannelLine(channel));
104
- return { found: true, channel };
117
+ const installed = findInstalledBrowser(deps);
118
+ if (undefined !== installed) {
119
+ deps.announce(usingChannelLine(installed.channel));
120
+ return undefined === installed.executablePath
121
+ ? { found: true, channel: installed.channel }
122
+ : { found: true, executablePath: installed.executablePath };
105
123
  }
106
124
  deps.announce(installingLine(chromiumInstallCommand(bundledPlaywrightVersion())));
107
125
  const ok = await deps.install();
@@ -207,6 +225,7 @@ const THIS_MACHINE = {
207
225
  env: process.env,
208
226
  exists: existsSync,
209
227
  pathProblem: (path) => chromiumPathProblem(path, process.platform),
228
+ platform: process.platform,
210
229
  };
211
230
  /**
212
231
  * The lease preflight's probe: "can a launch succeed?", which now includes the Chrome/Edge fallback
@@ -217,16 +236,29 @@ export async function probeLaunchableChromium(machine = THIS_MACHINE) {
217
236
  const configured = configuredChromiumProbe(machine.env, machine.pathProblem);
218
237
  if (configured !== undefined)
219
238
  return configured;
239
+ const platform = machine.platform ?? process.platform;
220
240
  try {
221
241
  const { chromium } = await import('playwright');
222
- const target = await resolveChromiumTarget(defaultDeps(chromium));
242
+ const target = await resolveChromiumTarget({
243
+ ...defaultDeps(chromium),
244
+ exists: machine.exists,
245
+ pathProblem: machine.pathProblem,
246
+ platform,
247
+ env: machine.env,
248
+ ...(machine === THIS_MACHINE
249
+ ? {}
250
+ : {
251
+ install: () => Promise.resolve(false),
252
+ announce: () => { },
253
+ }),
254
+ });
223
255
  if (target.found)
224
256
  return { exists: true };
225
257
  }
226
258
  catch {
227
- // No playwright at all — probeChromium below says so, with where it looked.
259
+ // No playwright at all — probeChromiumWithFallback below says so, with where it looked.
228
260
  }
229
- return probeChromium();
261
+ return probeChromiumWithFallback(machine);
230
262
  }
231
263
  /**
232
264
  * `doctor`'s probe: the plain Chromium probe, plus the installed browser that stands in when the
@@ -236,13 +268,18 @@ export async function probeChromiumWithFallback(machine = THIS_MACHINE) {
236
268
  const configured = configuredChromiumProbe(machine.env, machine.pathProblem);
237
269
  if (configured !== undefined)
238
270
  return configured;
239
- const probe = await probeChromium();
271
+ const probe = await probeChromium(machine.exists);
240
272
  if (probe.exists)
241
273
  return probe;
274
+ const platform = machine.platform ?? process.platform;
242
275
  const channel = findInstalledChannel({
243
276
  exists: machine.exists,
244
- platform: process.platform,
277
+ platform,
245
278
  env: machine.env,
246
279
  });
247
- return channel === undefined ? probe : { ...probe, fallback: CHANNEL_LABEL[channel] };
280
+ if (undefined !== channel) {
281
+ return { ...probe, fallback: CHANNEL_LABEL[channel] };
282
+ }
283
+ const fallbackPaths = CHANNEL_ORDER.flatMap((ch) => channelExecutableCandidates(ch, platform, machine.env));
284
+ return 0 === fallbackPaths.length ? probe : { ...probe, fallbackPaths };
248
285
  }
@@ -20,6 +20,11 @@ const HTTP_OK = 200;
20
20
  const NOT_THERE = new Set([404, 405]);
21
21
  /** Refused for good: too large (413), too many for one run (409). Retrying cannot change it. */
22
22
  const REFUSED_FOR_GOOD = new Set([409, 413]);
23
+ /**
24
+ * The platform takes only JPEG and answers 400 to anything else, which stops the cycle. A desktop
25
+ * capture from a page SDK before 3.7 is a PNG, so it stays on this machine.
26
+ */
27
+ const isJpeg = (image) => 0xff === image[0] && 0xd8 === image[1];
23
28
  /** The screenshots a run artifact lists, read defensively: a malformed entry is skipped. */
24
29
  function listed(payload) {
25
30
  const raw = 'object' === typeof payload && null !== payload
@@ -78,7 +83,7 @@ export async function syncScreenshots(deps) {
78
83
  done.add(keyOf(shot));
79
84
  sent[run.runId] = [...done];
80
85
  };
81
- if (image.byteLength > STEP_SCREENSHOT.MAX_BYTES) {
86
+ if (image.byteLength > STEP_SCREENSHOT.MAX_BYTES || !isJpeg(image)) {
82
87
  markDone();
83
88
  continue;
84
89
  }
@@ -57,6 +57,18 @@ export declare const WS_CLOSE_REASON: {
57
57
  */
58
58
  readonly INVALID_HELLO_CLOSE: "invalid hello: version skew. Put SDK, build plugin and server on one release.";
59
59
  };
60
+ /** WS close codes + reasons the bridge sends to the SDK (1008 = policy violation, 1013 = try again later). */
61
+ export declare const WS_CLOSE: {
62
+ readonly TOO_MANY_HANDSHAKES: readonly [1013, "too many pending handshakes"];
63
+ readonly HELLO_TIMEOUT: readonly [1008, "hello timeout"];
64
+ readonly INVALID_MESSAGE: readonly [1008, "invalid message"];
65
+ readonly INVALID_HELLO: readonly [1008, "invalid hello: version skew. Put SDK, build plugin and server on one release."];
66
+ readonly HELLO_DUPLICATE: readonly [1008, "hello already received"];
67
+ readonly AUTH_FAILED: readonly [1008, "authentication failed — restart the dev server; a reload keeps the old pairing token"];
68
+ readonly SESSION_LIMIT: readonly [1013, "session limit reached"];
69
+ readonly PROTOCOL_MISMATCH: readonly [1008, "protocol version mismatch — @reticlehq/browser and server disagree"];
70
+ readonly HANDLER_FAILED: readonly [1011, "the Reticle daemon failed handling a message — reload the page to reconnect"];
71
+ };
60
72
  interface BridgeOptions {
61
73
  port: number;
62
74
  host?: string;
@@ -107,10 +119,7 @@ export declare class Bridge {
107
119
  */
108
120
  attachSessionReady(handler: SessionReadyHandler): void;
109
121
  /** Register a callback fired the instant a session is created — used to attach the durable journal. */
110
- /**
111
- * Teardown handler fired when a session is removed (tab closed/disconnected). Without it the journal's
112
- * batched tail (< one flush batch) never reaches disk and the learned ambient map is discarded.
113
- */
122
+ /** Teardown when a session is removed (tab closed/disconnected). Additive — every handler runs. */
114
123
  attachSessionEnd(handler: (session: Session) => Promise<void>): void;
115
124
  /**
116
125
  * Register what to do when the panel's sync button is pressed.
@@ -101,7 +101,7 @@ export const WS_CLOSE_REASON = {
101
101
  INVALID_HELLO_CLOSE: 'invalid hello: version skew. Put SDK, build plugin and server on one release.',
102
102
  };
103
103
  /** WS close codes + reasons the bridge sends to the SDK (1008 = policy violation, 1013 = try again later). */
104
- const WS_CLOSE = {
104
+ export const WS_CLOSE = {
105
105
  TOO_MANY_HANDSHAKES: [1013, 'too many pending handshakes'],
106
106
  HELLO_TIMEOUT: [1008, 'hello timeout'],
107
107
  INVALID_MESSAGE: [1008, 'invalid message'],
@@ -190,8 +190,10 @@ export class Bridge {
190
190
  */
191
191
  #onSessionCreate = [];
192
192
  #onProjectAdopted = [];
193
- /** Fired when a session is removed — flushes its journal tail + persists what it learned. */
194
- #onSessionEnd;
193
+ // Fired when a session is removed. ADDITIVE: a single slot meant one teardown silently displaced
194
+ // the other — the same trap the create-side note describes. The durable journal flushes its tail;
195
+ // the page-error journal drops the session's dedupe memory.
196
+ #onSessionEnd = [];
195
197
  /** Wired by the daemon: push to the dashboard now, because somebody asked in the panel. */
196
198
  #onSyncRequest;
197
199
  #onHarnessRequest;
@@ -737,7 +739,9 @@ export class Bridge {
737
739
  log('session_disconnected', { sessionId: ended.id });
738
740
  // Best-effort teardown: flush the journal tail + persist ambient learning. Never awaited here
739
741
  // (the socket is already closing) and never allowed to reject into the close handler.
740
- void this.#onSessionEnd?.(ended).catch(() => undefined);
742
+ for (const handler of this.#onSessionEnd) {
743
+ void handler(ended).catch(() => undefined);
744
+ }
741
745
  // AFTER teardown is started, and deliberately not awaited on it. The payload says a
742
746
  // session ended, which is already true here; making it wait for the journal flush would
743
747
  // put a hook in the path of a socket close for no gain to the consumer.
@@ -835,12 +839,9 @@ export class Bridge {
835
839
  this.#onSessionReady.push(handler);
836
840
  }
837
841
  /** Register a callback fired the instant a session is created — used to attach the durable journal. */
838
- /**
839
- * Teardown handler fired when a session is removed (tab closed/disconnected). Without it the journal's
840
- * batched tail (< one flush batch) never reaches disk and the learned ambient map is discarded.
841
- */
842
+ /** Teardown when a session is removed (tab closed/disconnected). Additive — every handler runs. */
842
843
  attachSessionEnd(handler) {
843
- this.#onSessionEnd = handler;
844
+ this.#onSessionEnd.push(handler);
844
845
  }
845
846
  /**
846
847
  * Register what to do when the panel's sync button is pressed.
@@ -0,0 +1,6 @@
1
+ import type { DriveErrorCode } from '@reticlehq/core';
2
+ /** Structured, code-tagged failure so callers branch on cause, not message text. */
3
+ export declare class DriveError extends Error {
4
+ readonly code: DriveErrorCode;
5
+ constructor(code: DriveErrorCode, message: string);
6
+ }
@@ -0,0 +1,9 @@
1
+ /** Structured, code-tagged failure so callers branch on cause, not message text. */
2
+ export class DriveError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = 'DriveError';
8
+ }
9
+ }
@@ -47,10 +47,11 @@ export function matchMock(rules, req) {
47
47
  export async function applyOutcome(route, outcome, sleep) {
48
48
  if ('continue' === outcome.kind) {
49
49
  // Sleep FIRST, then let it through: the delay is the whole perturbation, and the response that
50
- // comes back is the server's own.
50
+ // comes back is the server's own. Fallback, not continue: a lease's context route (the CSP
51
+ // widening, #2016) must still see the request.
51
52
  if (outcome.delayMs !== undefined && outcome.delayMs > 0)
52
53
  await sleep(outcome.delayMs);
53
- await route.continue();
54
+ await route.fallback();
54
55
  return;
55
56
  }
56
57
  if ('abort' === outcome.kind) {
@@ -0,0 +1,34 @@
1
+ import type { Page } from 'playwright';
2
+ import { InputModeReason } from '@reticlehq/core';
3
+ import type { RealInputArgs } from './real-input.js';
4
+ type SleepFn = (ms: number) => Promise<void>;
5
+ /**
6
+ * Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
7
+ * Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
8
+ * cannot read the same args differently.
9
+ *
10
+ * A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
11
+ * `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
12
+ * The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
13
+ * keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
14
+ * — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
15
+ * nothing now has a reason to try it.
16
+ */
17
+ export declare function pressViaKeyboard(page: Page, args: RealInputArgs, sleep: SleepFn, now: () => number): Promise<number>;
18
+ /**
19
+ * The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
20
+ *
21
+ * `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
22
+ * That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
23
+ * — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
24
+ * presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
25
+ * `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
26
+ * routed to the synthetic path by the caller, which reports the reason rather than pretending a
27
+ * different gesture happened.
28
+ *
29
+ * Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
30
+ * the target. Two can apply at once, and the earlier one is the more unusual request — the reason
31
+ * should name that, not the generic "you passed a ref".
32
+ */
33
+ export declare function unspellablePressReason(ref: string, args: Record<string, unknown>): InputModeReason | undefined;
34
+ export {};