@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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Journal the uncaught page errors the SDK was never installed in time to see.
3
+ *
4
+ * An app whose module graph throws during the first navigation fires its `error` event while the
5
+ * page still has no Reticle SDK in it — the zero-install reader is only evaluated after `goto`
6
+ * resolves. The agent then saw a blank page and a bare console-error count, and had to re-run the
7
+ * app under `--enable-logging` to learn why (#1966). The pool's Playwright handle is attached
8
+ * before the navigation, so the server is the one witness that cannot miss the throw.
9
+ *
10
+ * The pool hands errors here with the lease id. The session for that lease usually does not exist
11
+ * yet, so errors buffer per lease and flush the moment the session connects
12
+ * (`bridge.attachSessionCreate`). One already connected means the error is journaled at once.
13
+ *
14
+ * The daemon runs for days, so both maps are told to forget: a lease that is released or reaped
15
+ * has its buffer dropped (`onLeaseClosed`), and a session that has ended has its dedupe memory
16
+ * dropped (`onSessionEnd`). Without those, every failed lease and every ended session leaves an
17
+ * entry behind forever.
18
+ *
19
+ * Deduplication: when the SDK IS installed it reports the same throw as `error.uncaught` — with
20
+ * the browser's `Uncaught ` prefix on the message, which Playwright's spelling lacks. Both
21
+ * spellings fold into one identity (`uncaughtIdentity`), and whichever source is seen first
22
+ * suppresses the other's copy. A journal write is deferred by a short window first, so an SDK
23
+ * event racing the page error lands inside the window and wins. The reverse race — our copy
24
+ * first, the SDK's right after — can still journal both; the SDK's own event is authoritative and
25
+ * only the redundant second copy is cost, not wrong evidence.
26
+ */
27
+ import { CONSOLE_EVENT, EventType, RETICLE_URL_PARAM } from '@reticlehq/core';
28
+ /** How many errors wait per lease for a session that may never connect. Older ones give way. */
29
+ const MAX_BUFFERED_PER_LEASE = 8;
30
+ /** How many recent error identities a session tracks — the dedupe memory for both sources. */
31
+ const MAX_TRACKED_IDENTITIES = 32;
32
+ /**
33
+ * How long a journal write waits before committing, so an SDK event for the same throw — the
34
+ * websocket hop against a CDP callback — can arrive first and claim the identity.
35
+ */
36
+ const DEDUPE_WAIT_MS = 50;
37
+ /**
38
+ * Fold the two spellings of one throw into one identity. Chromium prefixes a window-reported
39
+ * uncaught error with `Uncaught ` and often the error class (`Uncaught TypeError: ...`); the
40
+ * Playwright `pageerror` message is the bare exception text. Everything after the first line is a
41
+ * stack fragment and never matches, so it is dropped.
42
+ */
43
+ export function uncaughtIdentity(rawMessage) {
44
+ const withoutPrefix = rawMessage.replace(/^Uncaught\s+/i, '');
45
+ const firstLine = withoutPrefix.split('\n')[0] ?? withoutPrefix;
46
+ // Lazy `*?`: the greedy spelling cannot match a BARE `Error: ` — the leading character class
47
+ // eats the `E` and backtracking never gives it back.
48
+ return firstLine.replace(/^[A-Za-z0-9_]*?(?:Error|Exception):\s*/, '');
49
+ }
50
+ /** The first stack frame's file and line — the `source (file:line)` the issue asks the agent to see. */
51
+ const STACK_FRAME = /\bat\s+(\S+?):(\d+):\d+\b/;
52
+ function originOf(stack) {
53
+ if (stack === undefined) {
54
+ return {};
55
+ }
56
+ const frame = STACK_FRAME.exec(stack);
57
+ const source = frame?.[1];
58
+ const line = frame?.[2];
59
+ if (source === undefined || line === undefined) {
60
+ return {};
61
+ }
62
+ return { source, line: Number(line) };
63
+ }
64
+ function leaseParamOf(url) {
65
+ if (url === undefined) {
66
+ return undefined;
67
+ }
68
+ try {
69
+ return new URL(url).searchParams.get(RETICLE_URL_PARAM.SESSION) ?? undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ const realSleep = (ms) => {
76
+ return new Promise((resolve) => {
77
+ setTimeout(resolve, ms);
78
+ });
79
+ };
80
+ export function makePageErrorJournal(deps) {
81
+ const sleep = deps.sleep ?? realSleep;
82
+ /** Errors heard before their session connected, keyed by lease id. */
83
+ const buffered = new Map();
84
+ /** Per session: identities already claimed by the SDK or by us, oldest first. */
85
+ const seen = new Map();
86
+ const mark = (sessionId, identity) => {
87
+ const identities = seen.get(sessionId) ?? [];
88
+ if (identities.includes(identity)) {
89
+ return;
90
+ }
91
+ identities.push(identity);
92
+ if (identities.length > MAX_TRACKED_IDENTITIES) {
93
+ identities.shift();
94
+ }
95
+ seen.set(sessionId, identities);
96
+ };
97
+ const seenBefore = (sessionId, identity) => {
98
+ return (seen.get(sessionId) ?? []).includes(identity);
99
+ };
100
+ const journal = async (session, error, droppedCount) => {
101
+ await sleep(DEDUPE_WAIT_MS);
102
+ const identity = uncaughtIdentity(error.message);
103
+ if (seenBefore(session.id, identity)) {
104
+ return;
105
+ }
106
+ mark(session.id, identity);
107
+ const { source, line } = originOf(error.stack);
108
+ session.pushEvent({
109
+ t: 0, // pushEvent re-stamps with the session's own clock
110
+ type: EventType.CONSOLE_ERROR,
111
+ sessionId: session.id,
112
+ data: {
113
+ message: error.message,
114
+ kind: CONSOLE_EVENT.PAGE_ERROR,
115
+ ...(error.stack === undefined ? {} : { stack: error.stack }),
116
+ ...(source === undefined ? {} : { source }),
117
+ ...(line === undefined ? {} : { line }),
118
+ ...(droppedCount === undefined
119
+ ? {}
120
+ : { [CONSOLE_EVENT.DROPPED_PAGE_ERRORS]: droppedCount }),
121
+ },
122
+ });
123
+ };
124
+ // A page error must never break the connect path or the session that receives it.
125
+ const journalSafely = (session, error, droppedCount) => {
126
+ journal(session, error, droppedCount).catch(() => undefined);
127
+ };
128
+ return {
129
+ onPoolPageError(leaseId, error) {
130
+ const session = deps.resolveSession(leaseId);
131
+ if (session !== undefined) {
132
+ journalSafely(session, error);
133
+ return;
134
+ }
135
+ const entry = buffered.get(leaseId) ?? { errors: [], dropped: 0 };
136
+ if (entry.errors.length >= MAX_BUFFERED_PER_LEASE) {
137
+ entry.errors.shift();
138
+ entry.dropped += 1;
139
+ }
140
+ entry.errors.push(error);
141
+ buffered.set(leaseId, entry);
142
+ },
143
+ onSessionCreate(session) {
144
+ // Claim identities the SDK reports, so a page error the SDK already journaled adds no copy.
145
+ session.onEvent((event) => {
146
+ if (event.type !== EventType.ERROR_UNCAUGHT) {
147
+ return;
148
+ }
149
+ const message = event.data['message'];
150
+ if ('string' !== typeof message) {
151
+ return;
152
+ }
153
+ mark(session.id, uncaughtIdentity(message));
154
+ });
155
+ const leaseId = leaseParamOf(session.url);
156
+ if (leaseId === undefined) {
157
+ return;
158
+ }
159
+ const entry = buffered.get(leaseId);
160
+ if (entry === undefined) {
161
+ return;
162
+ }
163
+ buffered.delete(leaseId);
164
+ // The buffer is bounded, so a page that threw harder than the bound reports the loss on the
165
+ // first error that survived — the count names what the agent is not seeing.
166
+ entry.errors.forEach((error, i) => {
167
+ journalSafely(session, error, 0 === i && entry.dropped > 0 ? entry.dropped : undefined);
168
+ });
169
+ },
170
+ // A lease that is released or reaped will never flush: its session already ended, or its page
171
+ // died before any SDK connected. The buffer has nowhere to go, so it goes away.
172
+ onLeaseClosed(leaseId) {
173
+ buffered.delete(leaseId);
174
+ },
175
+ // The dedupe memory belongs to one session's lifetime; an ended session's identities would
176
+ // otherwise suppress nothing real and grow for the life of the daemon.
177
+ onSessionEnd(sessionId) {
178
+ seen.delete(sessionId);
179
+ },
180
+ };
181
+ }
@@ -8,11 +8,13 @@
8
8
  import { BrowserLaunchKind } from '@reticlehq/core/telemetry';
9
9
  import { ActionType, HIDE_RETICLE_CHROME_CSS, RETICLE_OVERLAY_SELECTOR } from '@reticlehq/core';
10
10
  import { launchChromium } from '../../launch-chromium.js';
11
+ import { pageViewportOptions } from '../../chromium-launch-options.js';
11
12
  import { getSessionMetrics } from '../../telemetry/session-metrics.js';
12
13
  import { classifyConnectFailure } from '../../telemetry/connect-failure.js';
13
14
  import { chromiumInstallCommand, chromiumInstallDepsCommand, bundledPlaywrightVersion, } from '../../command/cli/doctor/browser/chromium-hint.js';
14
15
  import { installNetworkMocks } from '../input/network-mock.js';
15
- import { performGesture } from '../input/real-input.js';
16
+ import { performGesture, withChromePassThrough, } from '../input/real-input.js';
17
+ import { widenDocumentCsp } from './csp-widen.js';
16
18
  /** A key addresses focus, not a point; the gesture driver takes a box for every action. */
17
19
  const NO_BOX = { x: 0, y: 0, width: 0, height: 0 };
18
20
  const sleepFor = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -91,17 +93,17 @@ export function gotoOptions(timeoutMs) {
91
93
  ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
92
94
  };
93
95
  }
94
- function wrapBrowser(browser) {
96
+ function wrapBrowser(browser, headless) {
95
97
  return {
96
98
  isConnected: () => browser.isConnected(),
97
99
  newContext: async (opts) => {
98
100
  // Media and viewport ride on the context, so the first paint of the first page has them.
99
101
  const { ignoreHTTPSErrors, ...emulate } = opts ?? {};
100
- const contextOpts = {
102
+ const context = await browser.newContext({
101
103
  ...(true === ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {}),
102
104
  ...emulate,
103
- };
104
- const context = await browser.newContext(0 < Object.keys(contextOpts).length ? contextOpts : undefined);
105
+ ...pageViewportOptions(headless, emulate.viewport),
106
+ });
105
107
  return {
106
108
  newPage: async () => {
107
109
  const page = await context.newPage();
@@ -121,11 +123,12 @@ function wrapBrowser(browser) {
121
123
  },
122
124
  // Three moves, same as performGesture: a single move to the center can be a no-op if
123
125
  // the pointer was already there, and CSS :hover needs a native hit-test to apply.
124
- hover: async (x, y) => {
126
+ // Through Reticle's own UI, like every other real gesture (#992).
127
+ hover: (x, y) => withChromePassThrough(page, async () => {
125
128
  await page.mouse.move(x, y);
126
129
  await page.mouse.move(x + 1, y);
127
130
  await page.mouse.move(x, y);
128
- },
131
+ }),
129
132
  // The same driver `reticle drive` presses with, so a held key and a chord read alike.
130
133
  emulateMedia: (media) => page.emulateMedia(media),
131
134
  press: (args) => performGesture(page, ActionType.PRESS, NO_BOX, args, sleepFor),
@@ -148,6 +151,12 @@ function wrapBrowser(browser) {
148
151
  bringToFront: () => page.bringToFront(),
149
152
  onCrash: (handler) => page.on('crash', handler),
150
153
  onConsole: (handler) => page.on('console', (msg) => handler(msg.text())),
154
+ // Playwright's `pageerror` carries the full exception even when the page's own window
155
+ // listener never ran — the import-time throw that lands before any SDK is injected.
156
+ onPageError: (handler) => page.on('pageerror', (error) => handler({
157
+ message: error.message,
158
+ ...(error.stack === undefined ? {} : { stack: error.stack }),
159
+ })),
151
160
  onDialog: (handler) => page.on('dialog', (dialog) => handler({ message: dialog.message(), dismiss: () => dialog.dismiss() })),
152
161
  addInitScript: async (script, arg) => {
153
162
  const handle = (await page.addInitScript(script, arg));
@@ -162,6 +171,10 @@ function wrapBrowser(browser) {
162
171
  addCookies: (cookies) => context.addCookies(cookies),
163
172
  grantPermissions: (permissions, opts) => context.grantPermissions(permissions, opts),
164
173
  clearPermissions: () => context.clearPermissions(),
174
+ // On the context, so a page route that falls back still reaches it. See csp-widen.ts.
175
+ widenCsp: async (origin, onWidened) => {
176
+ await context.route(ANY_URL, (route) => widenDocumentCsp(route, origin, onWidened));
177
+ },
165
178
  close: () => context.close(),
166
179
  };
167
180
  },
@@ -233,7 +246,7 @@ export function playwrightLauncher(opts = {}) {
233
246
  // number that actually costs memory and the one that explains a slow machine.
234
247
  const settle = getSessionMetrics().recordConnectAttempt(BrowserLaunchKind.POOLED);
235
248
  try {
236
- const browser = wrapBrowser(await launchChromium(chromium, headless));
249
+ const browser = wrapBrowser(await launchChromium(chromium, headless), headless);
237
250
  settle();
238
251
  return browser;
239
252
  }
@@ -21,6 +21,14 @@ export interface PooledPage {
21
21
  * "the page said nothing", never as "the page dialled correctly".
22
22
  */
23
23
  onConsole?(handler: (text: string) => void): void;
24
+ /**
25
+ * Fires for each error the page threw with no handler for it — an import-time module throw
26
+ * included. OPTIONAL like `onConsole`: a fake without it makes the pool hear nothing, which
27
+ * degrades to the old behaviour instead of inventing an error nobody reported. The handler must
28
+ * be installed BEFORE the first navigation — an import-time throw lands during `goto`, and a
29
+ * listener attached after it reads a broken page as healthy (#1966).
30
+ */
31
+ onPageError?(handler: (error: PooledPageError) => void): void;
24
32
  /**
25
33
  * Capture the page as a PNG, or a JPEG at `jpegQuality` (a live picture, where size matters more). OPTIONAL, like `onConsole`: a fake that does not implement it makes
26
34
  * the pool report "no provider", which is the correct degradation — an absent screenshotter must
@@ -91,6 +99,14 @@ export interface PooledDialog {
91
99
  readonly message: string;
92
100
  dismiss(): Promise<void>;
93
101
  }
102
+ /**
103
+ * One uncaught error the page threw, as the engine reported it. The message is the exception text
104
+ * without the console's `Uncaught ` prefix; the stack is present when the engine had one.
105
+ */
106
+ export interface PooledPageError {
107
+ readonly message: string;
108
+ readonly stack?: string;
109
+ }
94
110
  export interface InitScriptHandle {
95
111
  dispose(): Promise<void>;
96
112
  }
@@ -133,6 +149,12 @@ export interface PooledContext {
133
149
  }): Promise<void>;
134
150
  /** Drop every permission granted on this context. OPTIONAL, for the same reason. */
135
151
  clearPermissions?(): Promise<void>;
152
+ /**
153
+ * Add `origin` to the `connect-src` of each top document's CSP, and call `onWidened` when that
154
+ * changed a response (see `csp-widen.ts`). OPTIONAL: without it a deployed page with a strict CSP
155
+ * blocks the reader, and the lease says `sdk_never_dialled`, which is true.
156
+ */
157
+ widenCsp?(origin: string, onWidened: () => void): Promise<void>;
136
158
  }
137
159
  /** The launched browser. Real Playwright `Browser` satisfies this. */
138
160
  export interface PooledBrowser {
@@ -2,7 +2,7 @@
2
2
  * The pool's lease handle and lease record, its options, and its limits: the types and constants
3
3
  * `BrowserPool` is built from.
4
4
  */
5
- import type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledPage } from './pool-contract.js';
5
+ import type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledPage, PooledPageError } from './pool-contract.js';
6
6
  /** A leased context+page for one flow. `release` frees the slot and closes the context. */
7
7
  export interface Lease {
8
8
  readonly sessionId: string;
@@ -46,8 +46,27 @@ export interface BrowserPoolOptions {
46
46
  /**
47
47
  * The zero-install reader, built where the daemon knows its own port and pairing token. A thunk so
48
48
  * the ~450KB build is only read if some page ever needs it; undefined when there is none to give.
49
+ * Called with the lease url, because a page off loopback gets its own nonce, not the token.
49
50
  */
50
- zeroInstallScript?: () => string | undefined;
51
+ zeroInstallScript?: (url: string) => string | undefined;
52
+ /**
53
+ * The WebSocket origin the reader dials (`ws://localhost:<port>`). A lease on a page that is not
54
+ * on loopback adds it to the page's CSP `connect-src` (#2016). Absent: no page CSP is changed.
55
+ */
56
+ daemonOrigin?: string;
57
+ /**
58
+ * Sink for uncaught page errors — an import-time throw the SDK was never installed in time to
59
+ * see (#1966). Called with the lease id, so the wiring can reach the session that connects for
60
+ * this lease later. Optional: without it the pool hears errors and drops them, which is the
61
+ * behaviour the repo had before the sink existed.
62
+ */
63
+ onPageError?: (leaseId: string, error: PooledPageError) => void;
64
+ /**
65
+ * Sink for lease release — manual release, the reaper, and a crash reclaim all funnel through
66
+ * it. Lets the wiring drop state it kept FOR that lease (the page-error buffer) instead of
67
+ * holding it for the life of the daemon. Optional: no sink, nothing to clean up.
68
+ */
69
+ onLeaseClosed?: (leaseId: string) => void;
51
70
  /**
52
71
  * Launches a browser with a window, for a lease somebody wants to watch. Launched only when the
53
72
  * first headed lease is asked for. Absent: a headed lease is refused.
@@ -106,6 +125,8 @@ export interface ActiveLease {
106
125
  * context for "why did the page look frozen for a moment", not a log an agent needs to replay.
107
126
  */
108
127
  lastDialogMessage?: string;
128
+ /** Why the last screenshot of this lease failed, if it did. Cleared by the next one that works. */
129
+ captureError?: string;
109
130
  /**
110
131
  * The disposable handle of the caller's init script, when the lease was opened with one — a stub
111
132
  * of the bindings a desktop shell injects. Disposed with the lease, so a reload or a later
@@ -123,6 +144,18 @@ export interface ActiveLease {
123
144
  permissions: readonly string[];
124
145
  /** Whether its context accepts any TLS certificate (#1255). Fixed when the context is made. */
125
146
  ignoresHTTPSErrors: boolean;
147
+ /** `widenedFor`: the origin added to this page's CSP `connect-src`, once one was (#2016). */
148
+ csp: {
149
+ widenedFor?: string;
150
+ };
126
151
  }
127
152
  /** Whether the page's document has not finished loading. A page that cannot be asked counts as loaded. */
128
153
  export declare function pageLoading(page: PooledPage): Promise<boolean>;
154
+ /**
155
+ * The HTTP status a navigation ended on, or undefined when the page shape reports none.
156
+ *
157
+ * Real Playwright answers `goto` with a response object exposing `status()`; the fakes hand back
158
+ * whatever they like. A status accessor that THROWS says nothing usable, so it reads as absent
159
+ * rather than breaking the lease that is otherwise fine.
160
+ */
161
+ export declare function navStatusOf(navRes: unknown): number | undefined;
@@ -47,3 +47,26 @@ export async function pageLoading(page) {
47
47
  const state = await page.evaluate('document.readyState');
48
48
  return 'string' === typeof state && state !== 'complete';
49
49
  }
50
+ /**
51
+ * The HTTP status a navigation ended on, or undefined when the page shape reports none.
52
+ *
53
+ * Real Playwright answers `goto` with a response object exposing `status()`; the fakes hand back
54
+ * whatever they like. A status accessor that THROWS says nothing usable, so it reads as absent
55
+ * rather than breaking the lease that is otherwise fine.
56
+ */
57
+ export function navStatusOf(navRes) {
58
+ if (null === navRes || 'object' !== typeof navRes || !('status' in navRes)) {
59
+ return undefined;
60
+ }
61
+ const s = navRes.status;
62
+ if ('function' === typeof s) {
63
+ try {
64
+ const code = s.call(navRes);
65
+ return 'number' === typeof code ? code : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ return 'number' === typeof s ? s : undefined;
72
+ }
@@ -37,6 +37,74 @@ const HTML_CONTENT_TYPES = ['text/html', 'application/xhtml+xml'];
37
37
  */
38
38
  const NOT_HTML_GUARD = `if (globalThis.document !== undefined && !${JSON.stringify(HTML_CONTENT_TYPES)}.includes(globalThis.document.contentType)) ` +
39
39
  `return ${JSON.stringify(READER_NOT_HTML)};`;
40
+ /*
41
+ * Keep what the page says before the SDK's observers exist, and hand it to them after `connect()`.
42
+ *
43
+ * The observers install in `connect()`, which waits for DOMContentLoaded (below). The app's start-up
44
+ * scripts run before that, so a console error or an uncaught error while the page loaded was not
45
+ * seen, and a page that crashed at start-up gave a clean capture. Only the reader does this: it is
46
+ * installed before the page's scripts, and an app's own SDK connects from inside them.
47
+ *
48
+ * Kept in the order the page said it: `console.error` calls, uncaught script errors and unhandled
49
+ * rejections. After `connect()`, each console error is said again through the patched console with
50
+ * the native print off, so the console shows it once. Each error event is dispatched again on
51
+ * `window`, where the SDK listens. The app's own error listeners see a replayed error a second time.
52
+ * A resource that failed to load is not kept: the network observer reads it from resource timing.
53
+ */
54
+ const EARLY_KEEP = 'if ("function" === typeof globalThis.addEventListener && globalThis.console !== undefined) {\n' +
55
+ 'const early = { said: [], replaying: false };\n' +
56
+ 'globalThis.__reticleEarly = early;\n' +
57
+ 'const nativeError = globalThis.console.error;\n' +
58
+ 'globalThis.console.error = function (...args) {\n' +
59
+ ' if (early.said !== null) early.said.push(args);\n' +
60
+ ' if (!early.replaying) return nativeError.apply(this, args);\n' +
61
+ '};\n' +
62
+ 'early.keep = (e) => { if (early.said !== null && (e.type !== "error" || e instanceof globalThis.ErrorEvent)) early.said.push(e); };\n' +
63
+ 'globalThis.addEventListener("error", early.keep, true);\n' +
64
+ 'globalThis.addEventListener("unhandledrejection", early.keep);\n' +
65
+ '}';
66
+ const EARLY_REPLAY = 'const early = globalThis.__reticleEarly;\n' +
67
+ 'if (early !== undefined) {\n' +
68
+ ' globalThis.__reticleEarly = undefined;\n' +
69
+ ' globalThis.removeEventListener("error", early.keep, true);\n' +
70
+ ' globalThis.removeEventListener("unhandledrejection", early.keep);\n' +
71
+ ' const said = early.said;\n' +
72
+ ' early.said = null;\n' +
73
+ ' for (const item of said) {\n' +
74
+ ' if (Array.isArray(item)) {\n' +
75
+ ' early.replaying = true;\n' +
76
+ ' try { globalThis.console.error(...item); } finally { early.replaying = false; }\n' +
77
+ ' } else if ("error" === item.type) {\n' +
78
+ ' globalThis.dispatchEvent(new globalThis.ErrorEvent("error", { message: item.message, filename: item.filename, lineno: item.lineno, colno: item.colno, error: item.error }));\n' +
79
+ ' } else {\n' +
80
+ ' globalThis.dispatchEvent(new globalThis.PromiseRejectionEvent("unhandledrejection", { promise: item.promise, reason: item.reason }));\n' +
81
+ ' }\n' +
82
+ ' }\n' +
83
+ '}';
84
+ /*
85
+ * Connect once the DOM exists. The reader also runs as an init script on every later document of the
86
+ * lease, before the parser makes <head> and <body>. `connect()` mounts page UI before it dials the
87
+ * bridge, so it threw there and never dialled: every full navigation after the first page lost the
88
+ * lease's session (#1897). The bundle still loads at once, so the observers start early.
89
+ */
90
+ const CONNECT_WHEN_DOM_READY = (args) => `const connect = () => { globalThis.__reticleInstance.connect(${args}); ${EARLY_REPLAY} };\n` +
91
+ `if (globalThis.document !== undefined && 'loading' === globalThis.document.readyState) ` +
92
+ `globalThis.document.addEventListener('DOMContentLoaded', connect, { once: true });\n` +
93
+ `else connect();`;
94
+ /** Where every zod copy in a page keeps its shared settings (`z.config`). */
95
+ const ZOD_CONFIG = 'globalThis.__zod_globalConfig';
96
+ /*
97
+ * Turn zod `jitless` on for the page, unless the page set it itself.
98
+ *
99
+ * The reader is evaluated over CDP, where `new Function` works even on a page whose CSP forbids
100
+ * `unsafe-eval`. Zod's eval probe then answered true and cached it, and later parses compiled a fast
101
+ * path with `new Function` in the page: CSP threw, and the session stopped answering (#2016,
102
+ * nextjs.org). Zod reads `jitless` when a schema is made, and the SDK makes some schemas later, also
103
+ * inside a CDP evaluate. So the setting stays on.
104
+ * ponytail: the page's own zod then parses without its compiled fast path. Results are the same;
105
+ * only parse speed changes. Scope it to the bundle's own zod copy if that speed is ever seen.
106
+ */
107
+ const ZOD_JITLESS = `(${ZOD_CONFIG} ??= {}).jitless ??= true;`;
40
108
  /**
41
109
  * The script for a page: install the SDK if the page has none, then connect it to this daemon.
42
110
  *
@@ -47,7 +115,7 @@ const NOT_HTML_GUARD = `if (globalThis.document !== undefined && !${JSON.stringi
47
115
  */
48
116
  export function zeroInstallScript(bundle, opts) {
49
117
  const args = JSON.stringify(injectedConnectArgs(opts));
50
- return `(() => {\n${NOT_HTML_GUARD}\nif (!globalThis.__reticleInstance) {\n${bundle}\n}\nglobalThis.__reticleInstance.connect(${args});\n})();`;
118
+ return `(() => {\n${NOT_HTML_GUARD}\nif (!globalThis.__reticleInstance) {\n${EARLY_KEEP}\n${ZOD_JITLESS}\n${bundle}\n}\n${CONNECT_WHEN_DOM_READY(args)}\n})();`;
51
119
  }
52
120
  /** The page is not an HTML document, so the lease did not give it the zero-install reader. */
53
121
  export class NotHtmlDocumentError extends Error {
@@ -19,6 +19,14 @@ export interface DevCommand {
19
19
  /** The app's directory, when it is not the project root. The caller must run the command there. */
20
20
  directory?: string;
21
21
  }
22
+ /**
23
+ * The direct child folders of `directory` that are apps with a dev command, sorted by name.
24
+ *
25
+ * Only for a folder with no package.json of its own. A task folder holds the app one level down, and
26
+ * no workspace file lists it, so `workspacePackageDirs` never finds it. Folders that are not apps
27
+ * (no package.json, or no dev script), `node_modules` and hidden folders are skipped.
28
+ */
29
+ export declare function childDevApps(directory: string, read?: (path: string) => string | undefined, listDirs?: (path: string) => readonly string[]): readonly string[];
22
30
  /**
23
31
  * The dev command for the project in `directory`, or undefined when there is not one to be sure of.
24
32
  *
@@ -36,6 +36,7 @@ const YARN_DLX = 'yarn dlx';
36
36
  const INIT_VERB = 'init';
37
37
  const DEFAULT_PACKAGE_MANAGER = 'npm';
38
38
  const PACKAGE_JSON = 'package.json';
39
+ const PNPM_WORKSPACE = 'pnpm-workspace.yaml';
39
40
  /** `--port 4311` / `--port=4311`, the flag every dev server in this list accepts. */
40
41
  const PORT_FLAG = /--port[= ]\s*(\d+)/;
41
42
  /**
@@ -59,15 +60,17 @@ function readTextFile(path) {
59
60
  function pinnedPort(script) {
60
61
  const match = PORT_FLAG.exec(script) ?? PORT_SHORT_FLAG.exec(script) ?? PORT_ENV.exec(script);
61
62
  const digits = match?.[1];
62
- if (digits === undefined)
63
+ if (digits === undefined) {
63
64
  return undefined;
65
+ }
64
66
  const port = Number.parseInt(digits, 10);
65
67
  return Number.isSafeInteger(port) ? port : undefined;
66
68
  }
67
69
  /** The parsed package.json, or undefined for anything unreadable/unparseable. */
68
70
  function readManifest(raw) {
69
- if (raw === undefined)
71
+ if (raw === undefined) {
70
72
  return undefined;
73
+ }
71
74
  try {
72
75
  return JSON.parse(raw);
73
76
  }
@@ -77,11 +80,13 @@ function readManifest(raw) {
77
80
  }
78
81
  /** The scripts block of a parsed package.json, or an empty one. */
79
82
  function scriptsOf(manifest) {
80
- if (null === manifest || 'object' !== typeof manifest)
83
+ if (null === manifest || 'object' !== typeof manifest) {
81
84
  return {};
85
+ }
82
86
  const scripts = manifest.scripts;
83
- if (null === scripts || 'object' !== typeof scripts)
87
+ if (null === scripts || 'object' !== typeof scripts) {
84
88
  return {};
89
+ }
85
90
  return scripts;
86
91
  }
87
92
  /** The package manager the lockfiles under `directory` identify. Anything else is npm. */
@@ -98,8 +103,9 @@ function devCommandFor(directory, manager, read) {
98
103
  // spawns are the same.
99
104
  const desktop = desktopLaunch(manifest);
100
105
  if (desktop !== undefined) {
101
- if ('command' in desktop)
106
+ if ('command' in desktop) {
102
107
  return { command: desktop.command };
108
+ }
103
109
  const command = `${manager} run ${desktop.script}`;
104
110
  return {
105
111
  command: 0 < desktop.args.length ? `${command} ${desktop.args}` : command,
@@ -108,8 +114,9 @@ function devCommandFor(directory, manager, read) {
108
114
  }
109
115
  for (const script of DEV_SCRIPTS) {
110
116
  const body = scripts[script];
111
- if ('string' !== typeof body || 0 === body.trim().length)
117
+ if ('string' !== typeof body || 0 === body.trim().length) {
112
118
  continue;
119
+ }
113
120
  const port = pinnedPort(body);
114
121
  return {
115
122
  command: `${manager} run ${script}`,
@@ -119,6 +126,28 @@ function devCommandFor(directory, manager, read) {
119
126
  }
120
127
  return undefined;
121
128
  }
129
+ /** Folders never read as an app: dependencies, and anything hidden (`.git`, `.cache`). */
130
+ const NODE_MODULES = 'node_modules';
131
+ const HIDDEN_PREFIX = '.';
132
+ /**
133
+ * The direct child folders of `directory` that are apps with a dev command, sorted by name.
134
+ *
135
+ * Only for a folder with no package.json of its own. A task folder holds the app one level down, and
136
+ * no workspace file lists it, so `workspacePackageDirs` never finds it. Folders that are not apps
137
+ * (no package.json, or no dev script), `node_modules` and hidden folders are skipped.
138
+ */
139
+ export function childDevApps(directory, read = readTextFile, listDirs = readDirNames) {
140
+ if (read(join(directory, PACKAGE_JSON)) !== undefined) {
141
+ return [];
142
+ }
143
+ return listDirs(directory)
144
+ .filter((name) => NODE_MODULES !== name && !name.startsWith(HIDDEN_PREFIX))
145
+ .filter((name) => {
146
+ const child = join(directory, name);
147
+ return devCommandFor(child, packageManager(child, read), read) !== undefined;
148
+ })
149
+ .sort();
150
+ }
122
151
  /** Directory names under `path`, or none when it cannot be read. */
123
152
  function readDirNames(path) {
124
153
  try {
@@ -151,16 +180,35 @@ export function detectDevCommand(directory, read = readTextFile) {
151
180
  export function detectDevCommandInProject(directory, read = readTextFile, listDirs = readDirNames) {
152
181
  const manager = packageManager(directory, read);
153
182
  const here = devCommandFor(directory, manager, read);
154
- if (here !== undefined)
183
+ if (here !== undefined) {
155
184
  return here;
185
+ }
186
+ // No package.json and no pnpm workspace file: a task folder, with the app one level down. With two
187
+ // apps there, choosing the first would start the wrong one; the caller names them through
188
+ // `childDevApps`. With none, the workspace search below still looks a level deeper.
189
+ if (read(join(directory, PACKAGE_JSON)) === undefined &&
190
+ read(join(directory, PNPM_WORKSPACE)) === undefined) {
191
+ const [only, ...others] = childDevApps(directory, read, listDirs);
192
+ if (0 < others.length) {
193
+ return undefined;
194
+ }
195
+ if (only !== undefined) {
196
+ const child = join(directory, only);
197
+ const found = devCommandFor(child, packageManager(child, read), read);
198
+ if (found !== undefined) {
199
+ return { ...found, directory: only };
200
+ }
201
+ }
202
+ }
156
203
  const packages = workspacePackageDirs({
157
204
  readFile: (path) => read(join(directory, path)) ?? null,
158
205
  listDirs: (path) => listDirs(join(directory, path)),
159
206
  });
160
207
  for (const sub of packages) {
161
208
  const found = devCommandFor(join(directory, sub), manager, read);
162
- if (found !== undefined)
209
+ if (found !== undefined) {
163
210
  return { ...found, directory: sub };
211
+ }
164
212
  }
165
213
  return undefined;
166
214
  }