peachsim 0.1.527

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 (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,2154 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import {
6
+ devices,
7
+ listSelectableDeviceModels,
8
+ type DeviceModel,
9
+ } from 'sootsim-engine/settings'
10
+ import { WebSocket } from 'ws'
11
+ import { probePort } from '../../scripts/dev-server-scanner'
12
+ import { isAppDeepLinkTarget, openAppUrl } from '../../src/app-url'
13
+ import { isLoopbackHost } from '../../src/backend-origin'
14
+ import { resolveSootsimShellBaseUrlForBridgePort } from '../../src/bridge-constants'
15
+ import {
16
+ applyRNXConfigToUrl,
17
+ mergeRNXConfig,
18
+ RNX_CONFIG_QUERY_PARAM,
19
+ type RNXConfig,
20
+ } from '../../src/config'
21
+ import {
22
+ resolveConnectionInput,
23
+ type ResolvedDevBundle,
24
+ } from '../../src/dev-bundle-resolution'
25
+ import { flowTimeoutScale } from '../../src/flow-timeout-scale'
26
+ import {
27
+ isDaemonLockfileFresh,
28
+ isDevBridgeLockfileFresh,
29
+ readActiveRuntime,
30
+ readDaemonLockfile,
31
+ readDevBridgeLockfiles,
32
+ readLiveRuntimeVersions,
33
+ readSimulatorDriverPreference,
34
+ runtimeDir,
35
+ } from '../../src/home-paths'
36
+ import { applyHotMode } from '../../src/native-dev-bundle-url'
37
+ import { ensureProfile } from '../../src/profiles'
38
+ import { rnxPublicBrand } from '../../src/public-brand'
39
+ import {
40
+ resolveDevCheckoutRuntimeRoot,
41
+ runtimeVersionHostname,
42
+ } from '../../src/runtime-assets'
43
+ import { rnxRuntime } from '../../src/runtime-delivery'
44
+ import { loadRNXAppConfig } from '../app-config'
45
+ import {
46
+ type DiscoveredAppFontSpec,
47
+ discoverNativeLinkedAppFonts,
48
+ encodeAppFontsWire,
49
+ resolveMetroProjectRoot,
50
+ } from '../app-fonts'
51
+ import { resolveAppProject, type ResolvedAppProject } from '../app-project'
52
+ import { discoverConfigPluginAppSplash, encodeAppSplashWire } from '../app-splash'
53
+ import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
54
+ import {
55
+ clearCurrentSimId,
56
+ getStableOwnerPid,
57
+ readCurrentSimId,
58
+ saveCurrentSimId,
59
+ } from '../current-sim'
60
+ import { getDriver } from '../drivers/registry'
61
+ import { openUrl as openUrlInBrowser } from '../open-url'
62
+ import { rethrowIfExit, rnxExit } from '../run-rnx'
63
+ import { rnxSelfInvocation } from '../self-invocation'
64
+ import {
65
+ BridgeSimLockedError,
66
+ createBridge,
67
+ createBridgeFromParsed,
68
+ formatLockOwner,
69
+ parseBridgeCliArgs,
70
+ printBridgeWorldNotice,
71
+ resolveBridgePortForPin,
72
+ resolveBridgeWorld,
73
+ worldForBridgePort,
74
+ type BridgeSimInfo,
75
+ } from '../ws-bridge'
76
+ import { inspectDescribe, inspectWaitReady } from './inspect/core'
77
+ import { isAgentEnv } from './inspect/env'
78
+ import type { DriverId, DriverLaunchResult } from '../drivers/types'
79
+
80
+ interface ControlCommandOptions {
81
+ port?: number
82
+ timeoutMs?: number
83
+ runtimeConfig?: RNXConfig
84
+ }
85
+
86
+ interface DriverConnectWaitOptions {
87
+ attempts: number
88
+ intervalMs: number
89
+ timeoutMs: number
90
+ hostTimeoutMs: number
91
+ }
92
+
93
+ const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000
94
+ const DRIVER_CONNECT_INTERVAL_MS = 100
95
+ const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
96
+ const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
97
+
98
+ // built-in shell apps use pathname-based routing, not ?bundle= params
99
+ const SHELL_APPS: Record<string, string> = {
100
+ rn: '/rn',
101
+ connectrn: '/rn',
102
+ 'connect-rn': '/rn',
103
+ clock: '/app/clock',
104
+ 'native-ui': '/app/native-ui',
105
+ tamagui: '/app/tamagui',
106
+ settings: '/app/settings',
107
+ photos: '/app/photos',
108
+ camera: '/app/camera',
109
+ }
110
+
111
+ function sleep(ms: number) {
112
+ return new Promise((resolve) => setTimeout(resolve, ms))
113
+ }
114
+
115
+ function parsePositiveIntegerEnv(name: string): number | null {
116
+ const value = process.env[name]?.trim()
117
+ if (!value) return null
118
+ const parsed = Number(value)
119
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null
120
+ }
121
+
122
+ export function resolveDriverConnectWaitOptions(): DriverConnectWaitOptions {
123
+ const timeoutMs =
124
+ parsePositiveIntegerEnv('SOOTSIM_DRIVER_CONNECT_TIMEOUT_MS') ??
125
+ parsePositiveIntegerEnv('SOOTSIM_PW_CONNECT_TIMEOUT_MS') ??
126
+ DEFAULT_DRIVER_CONNECT_TIMEOUT_MS
127
+ return {
128
+ timeoutMs,
129
+ hostTimeoutMs: timeoutMs + DRIVER_CONNECT_HOST_GRACE_MS,
130
+ intervalMs: DRIVER_CONNECT_INTERVAL_MS,
131
+ attempts: Math.max(1, Math.ceil(timeoutMs / DRIVER_CONNECT_INTERVAL_MS)),
132
+ }
133
+ }
134
+
135
+ function isDriverId(value: string): value is DriverId {
136
+ return value === 'electron' || value === 'playwright'
137
+ }
138
+
139
+ function simUsesDriver(sim: BridgeSimInfo, driverId: DriverId): boolean {
140
+ if (driverId === 'playwright') {
141
+ return sim.meta?.sootsimHostDriver === 'playwright'
142
+ }
143
+ return sim.userAgent?.includes('Electron/') === true
144
+ }
145
+
146
+ // resolve headless for a driver launch. explicit flags always win
147
+ // (--headless → true, --headed → false). otherwise: a headed default
148
+ // on mac/windows. on linux with no DISPLAY/WAYLAND_DISPLAY there is no
149
+ // X server, so the playwright driver auto-flips to headless with a
150
+ // single-line note. electron ignores headless mode, so it defaults false.
151
+ function resolveDriverHeadless(args: string[], driverId: string): boolean {
152
+ if (args.includes('--headless')) return true
153
+ if (args.includes('--headed')) return false
154
+ if (
155
+ driverId === 'playwright' &&
156
+ process.platform === 'linux' &&
157
+ !process.env.DISPLAY &&
158
+ !process.env.WAYLAND_DISPLAY
159
+ ) {
160
+ console.error(
161
+ ' no display detected → running playwright headless (override with --headed)',
162
+ )
163
+ return true
164
+ }
165
+ return false
166
+ }
167
+
168
+ function normalizeKnownTarget(target: string): string {
169
+ return target.trim()
170
+ }
171
+
172
+ export async function ensureBridgeForOpen(
173
+ preferredPort: number,
174
+ explicitPort = false,
175
+ ): Promise<number> {
176
+ const lock = readDaemonLockfile()
177
+ assertExplicitBridgeLock(preferredPort, explicitPort, lock)
178
+ const preferredWorld = worldForBridgePort(preferredPort)
179
+ if (
180
+ (explicitPort || preferredWorld !== null) &&
181
+ (await canConnectToBridge(preferredPort, 250))
182
+ ) {
183
+ return preferredPort
184
+ }
185
+ if (explicitPort && preferredWorld !== null) {
186
+ throw new Error(`rnx open: requested bridge port ${preferredPort} is not reachable.`)
187
+ }
188
+ if (
189
+ !explicitPort &&
190
+ lock &&
191
+ isDaemonLockfileFresh(lock) &&
192
+ (await canConnectToBridge(lock.bridgePort, 500))
193
+ ) {
194
+ return lock.bridgePort
195
+ }
196
+ if (lock && isDaemonLockfileFresh(lock)) {
197
+ throw new Error(
198
+ `rnx bridge lockfile is fresh (port ${lock.bridgePort}) but the bridge is not reachable. run \`rnx daemon restart\` or \`rnx daemon uninstall\`.`,
199
+ )
200
+ }
201
+
202
+ await ensureRuntimeForOpen()
203
+ const child = spawnSootsim(['serve', '--quiet', '--port', String(preferredPort)])
204
+ let childExit: string | null = null
205
+ child.once('exit', (code, signal) => {
206
+ childExit = String(code ?? signal ?? 'unknown')
207
+ })
208
+ child.unref()
209
+
210
+ const deadline = Date.now() + OPEN_BRIDGE_SPAWN_TIMEOUT_MS
211
+ while (Date.now() < deadline) {
212
+ const nextLock = readDaemonLockfile()
213
+ assertExplicitBridgeLock(preferredPort, explicitPort, nextLock)
214
+ const selectedPort = explicitPort ? preferredPort : nextLock?.bridgePort
215
+ if (
216
+ nextLock &&
217
+ isDaemonLockfileFresh(nextLock) &&
218
+ selectedPort !== undefined &&
219
+ (await canConnectToBridge(selectedPort, 250))
220
+ ) {
221
+ console.error(
222
+ ` Started a local bridge on port ${selectedPort}. Run \`rnx daemon install\` to keep it ready between commands.`,
223
+ )
224
+ return selectedPort
225
+ }
226
+ if (childExit) {
227
+ throw new Error(`rnx bridge exited before becoming ready (exit ${childExit})`)
228
+ }
229
+ await sleep(150)
230
+ }
231
+
232
+ throw new Error(
233
+ `rnx bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`,
234
+ )
235
+ }
236
+
237
+ function assertExplicitBridgeLock(
238
+ preferredPort: number,
239
+ explicitPort: boolean,
240
+ lock: ReturnType<typeof readDaemonLockfile>,
241
+ ): void {
242
+ if (
243
+ !explicitPort ||
244
+ !lock ||
245
+ !isDaemonLockfileFresh(lock) ||
246
+ lock.bridgePort === preferredPort
247
+ ) {
248
+ return
249
+ }
250
+ if (
251
+ readDevBridgeLockfiles().some(
252
+ (devBridge) =>
253
+ devBridge.bridgePort === preferredPort && isDevBridgeLockfileFresh(devBridge),
254
+ )
255
+ ) {
256
+ return
257
+ }
258
+ throw new Error(
259
+ `${rnxPublicBrand.commandName} open: requested bridge port ${preferredPort}, but the active daemon owns port ${lock.bridgePort}. explicit --port never selects another bridge; stop that daemon or use an isolated RNX_HOME.`,
260
+ )
261
+ }
262
+
263
+ async function ensureRuntimeForOpen() {
264
+ const active = readActiveRuntime()
265
+ if (active && fs.existsSync(runtimeDir(active))) return
266
+ console.error(' installing rnx engine runtime...')
267
+ const { runRuntime } = await import('./runtime')
268
+ await runRuntime(['install'], {})
269
+ }
270
+
271
+ function spawnSootsim(args: string[]) {
272
+ const { executable, prefixArgs } = rnxSelfInvocation()
273
+ return spawn(executable, [...prefixArgs, ...args], {
274
+ detached: true,
275
+ stdio: 'ignore',
276
+ env: process.env,
277
+ })
278
+ }
279
+
280
+ function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
281
+ return new Promise((resolve) => {
282
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
283
+ let settled = false
284
+ const finish = (ok: boolean) => {
285
+ if (settled) return
286
+ settled = true
287
+ if (ws.readyState === WebSocket.OPEN) ws.close()
288
+ else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
289
+ resolve(ok)
290
+ }
291
+ ws.once('open', () => finish(true))
292
+ ws.on('error', () => finish(false))
293
+ setTimeout(() => finish(false), timeoutMs)
294
+ })
295
+ }
296
+
297
+ function looksLikeSootsimUrl(target: string): boolean {
298
+ try {
299
+ const url = new URL(target)
300
+ const pathname = url.pathname.replace(/\/+$/, '') || '/'
301
+ return (
302
+ url.searchParams.has('open') ||
303
+ url.searchParams.has('port') ||
304
+ url.searchParams.has('bundle') ||
305
+ url.searchParams.has('demo') ||
306
+ url.pathname.includes('/sootsim/index.html') ||
307
+ pathname === '/sootsim' ||
308
+ url.pathname === '/__soot' ||
309
+ url.pathname === '/__soot/' ||
310
+ pathname === '/rn' ||
311
+ /^\/rn\/[^/]+$/i.test(pathname) ||
312
+ /^\/app\/[^/]+$/i.test(pathname) ||
313
+ pathname === '/__soot/rn' ||
314
+ /^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
315
+ /^\/__soot\/app\/[^/]+$/i.test(pathname)
316
+ )
317
+ } catch {
318
+ return false
319
+ }
320
+ }
321
+
322
+ function looksLikeShellPathTarget(target: string): boolean {
323
+ try {
324
+ const url = new URL(target, 'http://sootsim.local')
325
+ if (url.origin !== 'http://sootsim.local') return false
326
+ const pathname = url.pathname.replace(/\/+$/, '') || '/'
327
+ return (
328
+ pathname === '/rn' ||
329
+ /^\/rn\/[^/]+$/i.test(pathname) ||
330
+ /^\/app\/[^/]+$/i.test(pathname) ||
331
+ pathname === '/__soot/rn' ||
332
+ /^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
333
+ /^\/__soot\/app\/[^/]+$/i.test(pathname)
334
+ )
335
+ } catch {
336
+ return false
337
+ }
338
+ }
339
+
340
+ function buildShellPathOpenUrl(target: string, baseUrl: string): string {
341
+ const targetUrl = new URL(target, 'http://sootsim.local')
342
+ const url = new URL(baseUrl)
343
+ const prefix = getShellRoutePrefix(url.pathname)
344
+ const routePath = targetUrl.pathname.startsWith('/__soot/')
345
+ ? targetUrl.pathname.slice('/__soot'.length)
346
+ : targetUrl.pathname
347
+
348
+ url.pathname = `${prefix}${routePath}`
349
+ url.search = targetUrl.search
350
+ url.hash = targetUrl.hash
351
+ return url.toString()
352
+ }
353
+
354
+ // rnx share/preview/build links (e.g. https://rnxsim.com/preview/<id>,
355
+ // https://contrast.dev/build/<owner>/<repo>/<branch>) are full player pages
356
+ // served by the Contrast app — NOT Metro bundles and NOT driveable shell sims.
357
+ // passing one to `rnx open` used to fall through to the bundle resolver,
358
+ // which fetched the HTML page and choked with "Unexpected token '<'". two
359
+ // hard reasons they can't become a CLI-driveable sim:
360
+ // 1. the engine only attaches to the local CLI bridge from a localhost /
361
+ // 127.0.0.1 origin (see engine `mcp/ws-bridge.ts` connect gate), so a
362
+ // remote preview page never registers a sim.
363
+ // 2. a captured share's transformed bundle is pinned to the engine build
364
+ // that produced it — its lazy `/engine-tenant/assets/*` chunk hashes
365
+ // only exist on that deploy, so a remote share can't be re-hosted
366
+ // through the local dev shell (the dynamic chunk import 404s).
367
+ // recognize these URLs so `open` opens them for viewing instead.
368
+ function looksLikeSootSimShareUrl(target: string): boolean {
369
+ try {
370
+ const pathname = new URL(target).pathname.replace(/\/+$/, '') || '/'
371
+ return /^\/preview\/[^/]+$/.test(pathname) || /^\/build\/.+/.test(pathname)
372
+ } catch {
373
+ return false
374
+ }
375
+ }
376
+
377
+ export async function resolveBundleTarget(target: string): Promise<string> {
378
+ const normalized = normalizeKnownTarget(target)
379
+ const resolved = await resolveConnectionInputForCli(normalized)
380
+ return resolved.bundleUrl
381
+ }
382
+
383
+ function localBasePort(target: string): number | null {
384
+ try {
385
+ const url = new URL(target.startsWith('http') ? target : `http://${target}`)
386
+ const pathname = url.pathname || '/'
387
+ if ((pathname !== '/' && pathname !== '') || url.search || url.hash) return null
388
+ if (!isLoopbackHost(url.hostname)) return null
389
+ const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
390
+ return Number.isFinite(port) && port > 0 ? port : null
391
+ } catch {
392
+ return null
393
+ }
394
+ }
395
+
396
+ // a dev server started alongside open connect may still be booting when the
397
+ // first probe lands. retry briefly so staging does not fail on a server that
398
+ // becomes probe-ready a few seconds later.
399
+ const DEV_BUNDLE_RESOLVE_ATTEMPTS = 15
400
+ const DEV_BUNDLE_RESOLVE_INTERVAL_MS = 1000
401
+
402
+ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDevBundle> {
403
+ const normalized = normalizeKnownTarget(target)
404
+ const registeredPort = /^\d+$/.test(normalized)
405
+ ? Number(normalized)
406
+ : localBasePort(normalized)
407
+
408
+ let lastError: Error | null = null
409
+ for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
410
+ if (registeredPort && registeredPort > 0) {
411
+ const discovered = await probePort(registeredPort)
412
+ // a provisional bundle URL is enough to LIST the server but not to open
413
+ // it; fall through to resolveConnectionInput, which asks the manifest
414
+ // again with a budget that fits a real dev server.
415
+ if (discovered && !discovered.bundleUrlProvisional) {
416
+ return {
417
+ bundleUrl: discovered.bundleUrl,
418
+ port: discovered.port,
419
+ framework: discovered.framework,
420
+ projectName: discovered.projectName,
421
+ }
422
+ }
423
+ }
424
+
425
+ try {
426
+ return await resolveConnectionInput(normalized)
427
+ } catch (error) {
428
+ if (!(error instanceof Error) || !error.message.startsWith("could not resolve a native bundle")) {
429
+ throw error
430
+ }
431
+ lastError = error
432
+ }
433
+ await sleep(DEV_BUNDLE_RESOLVE_INTERVAL_MS)
434
+ }
435
+ throw lastError ?? new Error("could not resolve a native bundle for " + normalized + " after retrying.")
436
+ }
437
+
438
+ function getShellRoutePrefix(pathname: string): string {
439
+ const normalized = pathname.replace(/\/+$/, '') || '/'
440
+ if (normalized === '/__soot' || normalized.startsWith('/__soot/')) {
441
+ return '/__soot'
442
+ }
443
+ return ''
444
+ }
445
+
446
+ function buildConnectOpenUrl(target: string, baseUrl: string): string {
447
+ const normalizedTarget = normalizeKnownTarget(target)
448
+ const url = new URL(baseUrl)
449
+ const prefix = getShellRoutePrefix(url.pathname)
450
+
451
+ url.pathname = `${prefix}/rn`
452
+ url.searchParams.delete('bundle')
453
+ url.searchParams.delete('demo')
454
+ url.searchParams.delete('app')
455
+ url.searchParams.delete('open')
456
+ url.searchParams.delete('port')
457
+
458
+ if (/^\d+$/.test(normalizedTarget)) {
459
+ url.pathname = `${prefix}/rn/${normalizedTarget}`
460
+ } else {
461
+ url.pathname = `${prefix}/rn`
462
+ url.searchParams.set('open', normalizedTarget)
463
+ }
464
+
465
+ return url.toString()
466
+ }
467
+
468
+ function shouldResolveBundleTargetInCli(target: string): boolean {
469
+ const normalizedTarget = normalizeKnownTarget(target)
470
+ if (/^\d+$/.test(normalizedTarget)) return true
471
+ if (/^https?:\/\//i.test(normalizedTarget)) return true
472
+ return /^(localhost|127\.0\.0\.1|\[::1\]|[^/]+\.localhost):\d+(?:\/.*)?$/i.test(
473
+ normalizedTarget,
474
+ )
475
+ }
476
+
477
+ async function buildResolvedBundleOpenUrl(
478
+ target: string,
479
+ baseUrl: string,
480
+ opts: BuildShellUrlOptions = {},
481
+ ): Promise<string> {
482
+ const resolved = opts.resolvedBundle ?? (await resolveConnectionInputForCli(target))
483
+ const url = new URL(baseUrl)
484
+ const prefix = getShellRoutePrefix(url.pathname)
485
+ url.pathname = `${prefix}/rn`
486
+ url.searchParams.delete('open')
487
+ url.searchParams.delete('port')
488
+ url.searchParams.delete('demo')
489
+ url.searchParams.delete('app')
490
+ const bundleUrl =
491
+ opts.hot === undefined
492
+ ? resolved.bundleUrl
493
+ : applyHotMode(resolved.bundleUrl, opts.hot)
494
+ url.searchParams.set('bundle', bundleUrl)
495
+ return url.toString()
496
+ }
497
+
498
+ function normalizeReplacementPath(filePath: string): string {
499
+ if (filePath.startsWith('~/')) {
500
+ return path.join(os.homedir(), filePath.slice(2))
501
+ }
502
+ if (path.isAbsolute(filePath)) return filePath
503
+ return path.resolve(process.cwd(), filePath)
504
+ }
505
+
506
+ function parseOpenRuntimeConfig(args: string[]): RNXConfig | undefined {
507
+ const modules: NonNullable<RNXConfig['modules']> = {}
508
+ const remap: Record<string, string> = {}
509
+
510
+ for (let i = 0; i < args.length; i++) {
511
+ if (args[i] === '--replace') {
512
+ const raw = args[i + 1]
513
+ if (!raw) {
514
+ console.error(' rnx open: --replace expects <module>=<file>')
515
+ rnxExit(1)
516
+ }
517
+ const eqIndex = raw.indexOf('=')
518
+ if (eqIndex <= 0 || eqIndex === raw.length - 1) {
519
+ console.error(' rnx open: --replace expects <module>=<file>')
520
+ rnxExit(1)
521
+ }
522
+ const moduleName = raw.slice(0, eqIndex).trim()
523
+ const filePath = normalizeReplacementPath(raw.slice(eqIndex + 1).trim())
524
+ if (!fs.existsSync(filePath)) {
525
+ console.error(` rnx open: replacement file not found: ${filePath}`)
526
+ rnxExit(1)
527
+ }
528
+ modules[moduleName] = { file: filePath }
529
+ i++
530
+ continue
531
+ }
532
+ // --remap <host:port>=<host:port> rewrites a guest fetch's host:port at
533
+ // runtime (config.network.remap). repeatable. lets a flow against an app
534
+ // that hardcodes a port reach a differently-bound service.
535
+ if (args[i] === '--remap') {
536
+ const raw = args[i + 1]
537
+ const eqIndex = raw ? raw.indexOf('=') : -1
538
+ if (!raw || eqIndex <= 0 || eqIndex === raw.length - 1) {
539
+ console.error(' rnx open: --remap expects <host:port>=<host:port>')
540
+ rnxExit(1)
541
+ }
542
+ remap[raw.slice(0, eqIndex).trim()] = raw.slice(eqIndex + 1).trim()
543
+ i++
544
+ continue
545
+ }
546
+ }
547
+
548
+ const config: RNXConfig = {}
549
+ if (Object.keys(modules).length > 0) config.modules = modules
550
+ if (Object.keys(remap).length > 0) config.network = { remap }
551
+ return config.modules || config.network ? config : undefined
552
+ }
553
+
554
+ async function resolveAppProjectForBundleUrl(
555
+ bundleUrl?: string,
556
+ ): Promise<ResolvedAppProject | null> {
557
+ if (!bundleUrl) return null
558
+ const advertisedRoot = await resolveMetroProjectRoot(bundleUrl)
559
+ return advertisedRoot ? resolveAppProject(advertisedRoot) : null
560
+ }
561
+
562
+ async function ensureConfiguredRuntime(version: string): Promise<void> {
563
+ if (fs.existsSync(path.join(runtimeDir(version), 'index.html'))) return
564
+ console.error(` installing configured engine runtime ${version}...`)
565
+ const result = await rnxRuntime.install({
566
+ version,
567
+ setActive: false,
568
+ protectVersions: readLiveRuntimeVersions(),
569
+ })
570
+ console.error(` installed engine runtime ${result.version}`)
571
+ }
572
+
573
+ export function applyRuntimeGenerationOrigin(baseUrl: string, version?: string): string {
574
+ const selectedVersion =
575
+ version ??
576
+ (resolveDevCheckoutRuntimeRoot() ? undefined : (readActiveRuntime() ?? undefined))
577
+ if (!selectedVersion) return baseUrl
578
+ const url = new URL(baseUrl)
579
+ if (!isLoopbackHost(url.hostname)) {
580
+ if (!version) return baseUrl
581
+ throw new Error(
582
+ `runtimeVersion ${selectedVersion} requires a local rnx host; got ${url.origin}`,
583
+ )
584
+ }
585
+ url.hostname = runtimeVersionHostname(selectedVersion)
586
+ return url.toString()
587
+ }
588
+
589
+ export function resolveDefaultShellBaseUrl(): string {
590
+ return resolveBridgeWorld().shellBaseUrl
591
+ }
592
+
593
+ /** the shell that belongs to a bridge port: the world a fresh lockfile claims
594
+ * for it, otherwise the shell that shares the port's own offset. every caller
595
+ * that has already settled on a bridge port goes through this, so a page can
596
+ * never open in one stack's shell while the CLI polls another's bridge. the
597
+ * development bridge registry keeps every live port addressable, while this
598
+ * function preserves an explicit caller's already-selected world. */
599
+ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
600
+ return (
601
+ worldForBridgePort(bridgePort)?.shellBaseUrl ??
602
+ resolveSootsimShellBaseUrlForBridgePort(bridgePort) ??
603
+ resolveDefaultShellBaseUrl()
604
+ )
605
+ }
606
+
607
+ // stamp the engine page url with `?appFonts=<url[::family]>,...` so the
608
+ // engine's app-font-loader registers native config-only fonts at boot. fonts
609
+ // loaded by JS already flow through normal runtime fetch capture; this covers
610
+ // UIAppFonts-style assets that Metro can serve but the guest bundle never
611
+ // fetches itself. auto-discovery only — scans the repo for expo-font config
612
+ // plugin declarations, no user-facing flag.
613
+ //
614
+ // each discovered font is HEAD-checked against the metro host before we
615
+ // include it on the URL. fonts whose metro asset URL 404s (typo in the
616
+ // expo-font plugin config, missing file, or a wrong app.config picked up
617
+ // by the scan) are dropped silently so the engine never tries to fetch a
618
+ // known-bad URL at boot — that would just log a registration failure and
619
+ // confuse anyone reading the page console.
620
+ async function stampAppFonts(
621
+ urlStr: string,
622
+ project?: ResolvedAppProject | null,
623
+ ): Promise<string> {
624
+ try {
625
+ const url = new URL(urlStr)
626
+ const bundleUrl = url.searchParams.get('bundle') || ''
627
+ if (!bundleUrl || !project) return urlStr
628
+ const specs = discoverNativeLinkedAppFonts({
629
+ bundleUrl,
630
+ projectDir: project.appDir,
631
+ })
632
+ if (specs.length === 0) return urlStr
633
+ const reachable = await filterReachableAppFonts(specs)
634
+ if (reachable.length === 0) return urlStr
635
+ url.searchParams.set('appFonts', encodeAppFontsWire(reachable))
636
+ return url.toString()
637
+ } catch {
638
+ return urlStr
639
+ }
640
+ }
641
+
642
+ // HEAD-check each font URL with a short timeout. metro returns 200 (or
643
+ // sometimes a redirect) for assets it can resolve and 500 with an ENOENT
644
+ // trace for paths it cannot. anything not in the 2xx-3xx range gets dropped.
645
+ async function filterReachableAppFonts(
646
+ specs: DiscoveredAppFontSpec[],
647
+ ): Promise<DiscoveredAppFontSpec[]> {
648
+ const results = await Promise.all(
649
+ specs.map(async (spec): Promise<DiscoveredAppFontSpec | null> => {
650
+ const controller = new AbortController()
651
+ const timer = setTimeout(() => controller.abort(), 1500)
652
+ try {
653
+ const res = await fetch(spec.url, {
654
+ method: 'HEAD',
655
+ signal: controller.signal,
656
+ })
657
+ if (res.status >= 200 && res.status < 400) return spec
658
+ return null
659
+ } catch {
660
+ return null
661
+ } finally {
662
+ clearTimeout(timer)
663
+ }
664
+ }),
665
+ )
666
+ const reachable: DiscoveredAppFontSpec[] = []
667
+ for (const spec of results) {
668
+ if (spec !== null) reachable.push(spec)
669
+ }
670
+ return reachable
671
+ }
672
+
673
+ async function resolveShellUrl(
674
+ target: string,
675
+ baseUrl: string,
676
+ opts: BuildShellUrlOptions = {},
677
+ ): Promise<string> {
678
+ // no target = open home screen
679
+ if (!target) {
680
+ return new URL(baseUrl).toString()
681
+ }
682
+
683
+ if (looksLikeSootsimUrl(target)) {
684
+ const url = new URL(target)
685
+ const base = new URL(baseUrl)
686
+ const baseConfig = base.searchParams.get(RNX_CONFIG_QUERY_PARAM)
687
+ if (baseConfig && !url.searchParams.has(RNX_CONFIG_QUERY_PARAM)) {
688
+ url.searchParams.set(RNX_CONFIG_QUERY_PARAM, baseConfig)
689
+ }
690
+ return url.toString()
691
+ }
692
+
693
+ if (looksLikeShellPathTarget(target)) {
694
+ return buildShellPathOpenUrl(target, baseUrl)
695
+ }
696
+
697
+ // check if this is a shell app (pathname-based route, not bundle)
698
+ const shellPath = SHELL_APPS[target.toLowerCase()]
699
+ if (shellPath) {
700
+ const url = new URL(baseUrl)
701
+ const prefix = getShellRoutePrefix(url.pathname)
702
+ url.pathname = `${prefix}${shellPath}`
703
+ return url.toString()
704
+ }
705
+
706
+ if (shouldResolveBundleTargetInCli(target)) {
707
+ return buildResolvedBundleOpenUrl(target, baseUrl, opts)
708
+ }
709
+
710
+ return buildConnectOpenUrl(target, baseUrl)
711
+ }
712
+
713
+ // stamp the engine page url with `?appSplash=<encodeURIComponent(JSON)>` so the
714
+ // engine paints the app's REAL configured splash image + background on boot
715
+ // (instead of the contrast device-default chrome). same discovery path as
716
+ // fonts: scan the bundle's project for the expo-splash-screen /
717
+ // react-native-bootsplash config plugin (or legacy `expo.splash`). the metro
718
+ // manifest fallback in the engine covers the typed-URL case; this wire is the
719
+ // instant-first-paint optimization.
720
+ async function stampAppSplash(
721
+ urlStr: string,
722
+ project?: ResolvedAppProject | null,
723
+ ): Promise<string> {
724
+ try {
725
+ const url = new URL(urlStr)
726
+ const bundleUrl = url.searchParams.get('bundle') || ''
727
+ if (!bundleUrl || !project) return urlStr
728
+ const spec = discoverConfigPluginAppSplash({
729
+ bundleUrl,
730
+ projectDir: project.appDir,
731
+ })
732
+ if (!spec) return urlStr
733
+ // HEAD-check the image before stamping so a typo'd/missing splash path never
734
+ // makes the engine fetch a known-bad URL at boot.
735
+ if (spec.imageUrl) {
736
+ const reachable = await splashImageReachable(spec.imageUrl)
737
+ if (!reachable) return urlStr
738
+ }
739
+ url.searchParams.set('appSplash', encodeAppSplashWire(spec))
740
+ return url.toString()
741
+ } catch {
742
+ return urlStr
743
+ }
744
+ }
745
+
746
+ async function splashImageReachable(imageUrl: string): Promise<boolean> {
747
+ const controller = new AbortController()
748
+ const timer = setTimeout(() => controller.abort(), 1500)
749
+ try {
750
+ const res = await fetch(imageUrl, { method: 'HEAD', signal: controller.signal })
751
+ return res.status >= 200 && res.status < 400
752
+ } catch {
753
+ return false
754
+ } finally {
755
+ clearTimeout(timer)
756
+ }
757
+ }
758
+
759
+ export interface BuildShellUrlOptions {
760
+ // when set, forces the metro `hot` query on the resolved bundle URL. true
761
+ // for interactive opens (HMR runtime in bundle, edits hot-apply), false
762
+ // for `--driver playwright`/agent runs (no in-bundle HMR wiring, no ws
763
+ // chatter, deterministic e2e). undefined leaves whatever value metro's
764
+ // manifest emitted — matches real iOS sims so metro's bundle cache is
765
+ // shared. set by `runOpenCommand` only.
766
+ hot?: boolean
767
+ // device model slug (e.g. iphone-16) stamped onto the engine URL as
768
+ // ?device=<model> — the shell reads it at boot (main.tsx) and live-switch
769
+ // uses the same param. without this, every driver-opened sim silently ran
770
+ // whatever profile the shell last persisted.
771
+ device?: string
772
+ resolvedBundle?: ResolvedDevBundle
773
+ project?: ResolvedAppProject | null
774
+ // deterministic-capture mode, stamped onto the engine URL as ?proof=1. the
775
+ // shell worker, compositor worker and tenant worker each read it off the
776
+ // host page URL at boot (syncProofModeFromInitUrl) and pin everything that
777
+ // would otherwise differ frame to frame: the TextInput caret stops blinking
778
+ // and stays visible, and notifications stop auto-dismissing. capture-frame
779
+ // also reads the raw param and stops clipping the device corners, so a
780
+ // capture matches simctl's rectangular framebuffer. without it a
781
+ // screen holding a focused text field never reaches three identical idle
782
+ // frames, so the screenshot guard burns all 12 attempts and then fails.
783
+ proof?: boolean
784
+ }
785
+
786
+ function bundleUrlOf(urlStr: string): string | undefined {
787
+ try {
788
+ return new URL(urlStr).searchParams.get('bundle') || undefined
789
+ } catch {
790
+ return undefined
791
+ }
792
+ }
793
+
794
+ export async function buildShellUrl(
795
+ target: string,
796
+ baseUrl: string = resolveDefaultShellBaseUrl(),
797
+ opts: BuildShellUrlOptions = {},
798
+ ): Promise<string> {
799
+ const resolved = await resolveShellUrl(target, baseUrl, opts)
800
+ // callers that hand us a bare port or url (the flow/maestro runner, the
801
+ // electron companion) have no project to pass. derive it from the bundle
802
+ // metro advertises so those paths get the same UIAppFonts and splash
803
+ // stamping an `rnx open` gets. without it a flow renders the app's text in a
804
+ // fallback typeface, which then reads as a conformance diff.
805
+ const project =
806
+ opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
807
+ const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project)
808
+ if (!opts.device && !opts.proof) return stamped
809
+ const url = new URL(stamped)
810
+ if (opts.device) url.searchParams.set('device', opts.device)
811
+ if (opts.proof) url.searchParams.set('proof', '1')
812
+ return url.toString()
813
+ }
814
+
815
+ function deriveCurrentSimBaseUrl(
816
+ sim: BridgeSimInfo | null | undefined,
817
+ fallback: string,
818
+ ): string {
819
+ const source = sim?.url || sim?.origin || fallback
820
+ try {
821
+ const url = new URL(source)
822
+ url.searchParams.delete('bundle')
823
+ url.searchParams.delete('demo')
824
+ url.searchParams.delete('app')
825
+ url.searchParams.delete('open')
826
+ url.searchParams.delete('port')
827
+ url.searchParams.delete('inspectOpen')
828
+ // appFonts is target-specific (it points at the metro asset endpoint of
829
+ // the bundle being launched). carrying it over from a prior sim URL
830
+ // means a stale font URL from an earlier launch leaks into the new one,
831
+ // and `stampAppFonts` doesn't get the chance to re-derive against the
832
+ // current bundle. drop it so the rebuild starts clean.
833
+ url.searchParams.delete('appFonts')
834
+ // appSplash is target-specific in the same way — drop it so the configured
835
+ // splash re-derives against the current bundle.
836
+ url.searchParams.delete('appSplash')
837
+ return url.toString()
838
+ } catch {
839
+ return fallback
840
+ }
841
+ }
842
+
843
+ export async function buildOpenUrl(
844
+ target: string,
845
+ baseUrl: string,
846
+ openToken: string,
847
+ opts: BuildShellUrlOptions = {},
848
+ ): Promise<string> {
849
+ const url = new URL(await buildShellUrl(target, baseUrl, opts))
850
+ url.searchParams.set('inspectOpen', openToken)
851
+ return url.toString()
852
+ }
853
+
854
+ async function waitForSimReady(
855
+ wsPort: number,
856
+ commandTimeoutMs: number,
857
+ simId: string,
858
+ opts: {
859
+ attempts?: number
860
+ intervalMs?: number
861
+ minNodeCount?: number
862
+ } = {},
863
+ ) {
864
+ // the ceiling here is attempts * intervalMs. a hosted launchApp re-evals the
865
+ // whole guest bundle over a tunnel, which routinely outlasts the local
866
+ // default, so scale the poll ceiling by the same factor the flow runner
867
+ // scales its own waits. this still returns the moment the sim reports enough
868
+ // nodes; only the deadline moves, and locally the scale is 1.
869
+ const intervalMs = opts.intervalMs ?? 500
870
+ const attempts = opts.attempts ?? Math.round(30 * flowTimeoutScale())
871
+ const minNodeCount = opts.minNodeCount ?? 10
872
+
873
+ // a poll that only ever reports "timed out" cannot say whether the guest was
874
+ // still booting, never installed the test hook, or was unreachable, so keep
875
+ // the last observation and hand it to the caller for the failure message.
876
+ let lastCount: unknown
877
+ let lastError = ''
878
+
879
+ for (let i = 0; i < attempts; i++) {
880
+ const bridge = createBridge(wsPort, {
881
+ commandTimeoutMs,
882
+ simId,
883
+ simIdSource: 'flag',
884
+ })
885
+ try {
886
+ const count = await bridge.send({
887
+ type: 'evaluate',
888
+ code: '(async () => (await window.__sootsimTest?.getNodeCount()) ?? null)()',
889
+ })
890
+ lastCount = count
891
+ lastError = ''
892
+ if (typeof count === 'number' && count > minNodeCount) {
893
+ return { bridge, count }
894
+ }
895
+ } catch (err) {
896
+ lastError = err instanceof Error ? err.message : String(err)
897
+ }
898
+ bridge.close()
899
+ await sleep(intervalMs)
900
+ }
901
+
902
+ return { bridge: null, attempts, intervalMs, minNodeCount, lastCount, lastError }
903
+ }
904
+
905
+ export async function waitForSimMatch(
906
+ wsPort: number,
907
+ commandTimeoutMs: number,
908
+ predicate: (sim: BridgeSimInfo) => boolean,
909
+ opts: { attempts?: number; intervalMs?: number } = {},
910
+ ) {
911
+ const attempts = opts.attempts ?? 30
912
+ const intervalMs = opts.intervalMs ?? 500
913
+ for (let i = 0; i < attempts; i++) {
914
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
915
+ try {
916
+ const sims = await bridge.listSims()
917
+ const match = sims.find(predicate)
918
+ if (match) return match
919
+ } catch {
920
+ // server/sim may still be booting
921
+ } finally {
922
+ bridge.close()
923
+ }
924
+ await sleep(intervalMs)
925
+ }
926
+ return null
927
+ }
928
+
929
+ // same as waitForSimMatch, but also watches a detached host PID so a host
930
+ // crash mid-connect is surfaced as a distinct outcome rather than waiting
931
+ // out the full timeout (and silently treating a stale bridge entry as
932
+ // success). 'host-exit' wins as soon as the host process is gone, even if
933
+ // the bridge still lists the sim.
934
+ export type WaitForSimMatchOrHostExitResult =
935
+ | { kind: 'match'; sim: BridgeSimInfo }
936
+ | { kind: 'host-exit' }
937
+ | { kind: 'timeout' }
938
+
939
+ export async function waitForSimMatchOrHostExit(
940
+ wsPort: number,
941
+ commandTimeoutMs: number,
942
+ predicate: (sim: BridgeSimInfo) => boolean,
943
+ hostPid: number | undefined,
944
+ opts: { attempts?: number; intervalMs?: number } = {},
945
+ ): Promise<WaitForSimMatchOrHostExitResult> {
946
+ const attempts = opts.attempts ?? 30
947
+ const intervalMs = opts.intervalMs ?? 500
948
+ const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
949
+ const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid)
950
+ for (let i = 0; i < attempts; i++) {
951
+ if (hostExited()) {
952
+ return { kind: 'host-exit' }
953
+ }
954
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
955
+ try {
956
+ const sims = await bridge.listSims()
957
+ const match = sims.find(predicate)
958
+ if (match) {
959
+ if (hostExited()) return { kind: 'host-exit' }
960
+ return { kind: 'match', sim: match }
961
+ }
962
+ } catch {
963
+ // server/sim may still be booting
964
+ } finally {
965
+ bridge.close()
966
+ }
967
+ if (hostExited()) {
968
+ return { kind: 'host-exit' }
969
+ }
970
+ await sleep(intervalMs)
971
+ }
972
+ return { kind: 'timeout' }
973
+ }
974
+
975
+ async function waitForSimGone(
976
+ wsPort: number,
977
+ commandTimeoutMs: number,
978
+ simId: string,
979
+ opts: { attempts?: number; intervalMs?: number } = {},
980
+ ) {
981
+ const attempts = opts.attempts ?? 20
982
+ const intervalMs = opts.intervalMs ?? 250
983
+ for (let i = 0; i < attempts; i++) {
984
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
985
+ try {
986
+ const sims = await bridge.listSims()
987
+ const sim = sims.find((entry) => entry.id === simId)
988
+ if (!sim || sim.readyState !== 'open') {
989
+ return true
990
+ }
991
+ } catch {
992
+ return true
993
+ } finally {
994
+ bridge.close()
995
+ }
996
+ await sleep(intervalMs)
997
+ }
998
+ return false
999
+ }
1000
+
1001
+ export function playwrightHostPidForSim(sim: BridgeSimInfo): number | null {
1002
+ const meta = sim.meta
1003
+ if (!meta || meta.sootsimHostDriver !== 'playwright') return null
1004
+ const pid = Number(meta.sootsimHostPid)
1005
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return null
1006
+ return pid
1007
+ }
1008
+
1009
+ export function signalDriverLaunchConnected(result: DriverLaunchResult): boolean {
1010
+ if (!result.connectAckFile) return false
1011
+ try {
1012
+ fs.writeFileSync(
1013
+ result.connectAckFile,
1014
+ `${JSON.stringify({ connectedAt: Date.now(), pid: result.pid ?? null })}\n`,
1015
+ { flag: 'w' },
1016
+ )
1017
+ return true
1018
+ } catch {
1019
+ return false
1020
+ }
1021
+ }
1022
+
1023
+ function printDriverDiagnosticLogTail(result: DriverLaunchResult) {
1024
+ if (!result.diagnosticLogPath) return
1025
+ try {
1026
+ const detail = fs.readFileSync(result.diagnosticLogPath, 'utf8').trim()
1027
+ if (!detail) {
1028
+ console.error(` ${path.basename(result.diagnosticLogPath)} was empty`)
1029
+ return
1030
+ }
1031
+ const shown = detail.length > 4_000 ? `…\n${detail.slice(-4_000)}` : detail
1032
+ console.error(` ${path.basename(result.diagnosticLogPath)}:`)
1033
+ console.error(shown)
1034
+ } catch (err) {
1035
+ const message = err instanceof Error ? err.message : String(err)
1036
+ console.error(
1037
+ ` failed to read ${path.basename(result.diagnosticLogPath)}: ${message}`,
1038
+ )
1039
+ }
1040
+ }
1041
+
1042
+ function isProcessAlive(pid: number): boolean {
1043
+ try {
1044
+ process.kill(pid, 0)
1045
+ return true
1046
+ } catch (err) {
1047
+ return (err as NodeJS.ErrnoException)?.code === 'EPERM'
1048
+ }
1049
+ }
1050
+
1051
+ async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise<boolean> {
1052
+ const deadline = Date.now() + timeoutMs
1053
+ while (Date.now() < deadline) {
1054
+ if (!isProcessAlive(pid)) return true
1055
+ await sleep(100)
1056
+ }
1057
+ return !isProcessAlive(pid)
1058
+ }
1059
+
1060
+ // a sim record's host pid can outlive its process; after pid reuse the number
1061
+ // may point at an unrelated process (real incident 2026-07-14: a stale record
1062
+ // made `rnx close` SIGTERM the shared vite shell dev server, taking the
1063
+ // whole dev stack down). only signal a pid whose argv proves it is still a
1064
+ // rnx playwright host launcher.
1065
+ function isPlaywrightHostProcess(pid: number): boolean {
1066
+ // `ps eww` prints argv then the process ENVIRONMENT for same-user
1067
+ // processes. the marker must live in the env SUFFIX (everything past the
1068
+ // plain argv), because argv text can contain the string coincidentally —
1069
+ // e.g. a shell wrapper that exports SOOTSIM_PW_* vars in its script body.
1070
+ const plain = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
1071
+ encoding: 'utf8',
1072
+ stdio: ['ignore', 'pipe', 'pipe'],
1073
+ })
1074
+ const withEnv = spawnSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], {
1075
+ encoding: 'utf8',
1076
+ stdio: ['ignore', 'pipe', 'pipe'],
1077
+ })
1078
+ if (plain.status !== 0 || withEnv.status !== 0) return false
1079
+ const argv = (plain.stdout || '').trimEnd()
1080
+ const full = (withEnv.stdout || '').trimEnd()
1081
+ const envSuffix = full.startsWith(argv) ? full.slice(argv.length) : full
1082
+ return envSuffix.includes('SOOTSIM_PW_URL=')
1083
+ }
1084
+
1085
+ export async function terminatePlaywrightHostsForSims(
1086
+ sims: BridgeSimInfo[],
1087
+ ids: string[],
1088
+ ) {
1089
+ const wanted = new Set(ids)
1090
+ const pids = new Set<number>()
1091
+ for (const sim of sims) {
1092
+ if (!wanted.has(sim.id)) continue
1093
+ const pid = playwrightHostPidForSim(sim)
1094
+ if (pid) pids.add(pid)
1095
+ }
1096
+ for (const pid of pids) {
1097
+ if (!isPlaywrightHostProcess(pid)) {
1098
+ console.log(` skipped pid ${pid}: not a rnx playwright host (stale sim record)`)
1099
+ continue
1100
+ }
1101
+ try {
1102
+ process.kill(pid, 'SIGTERM')
1103
+ } catch {}
1104
+ if (await waitForProcessExit(pid)) {
1105
+ console.log(` closed playwright host process ${pid}`)
1106
+ continue
1107
+ }
1108
+ try {
1109
+ process.kill(pid, 'SIGKILL')
1110
+ console.log(` force-closed playwright host process ${pid}`)
1111
+ } catch {}
1112
+ }
1113
+ }
1114
+
1115
+ function resolveTargetSim(sims: BridgeSimInfo[], requestedId?: string): BridgeSimInfo {
1116
+ if (requestedId) {
1117
+ const normalizedId = requestedId.trim()
1118
+ const match = sims.find((sim) => sim.id === normalizedId)
1119
+ if (!match) {
1120
+ throw new Error(`no sim connected with id ${normalizedId}`)
1121
+ }
1122
+ return match
1123
+ }
1124
+ const primary = sims.find((sim) => sim.isPrimary && sim.readyState === 'open')
1125
+ if (primary) return primary
1126
+ const firstOpen = sims.find((sim) => sim.readyState === 'open')
1127
+ if (firstOpen) return firstOpen
1128
+ throw new Error('no sim connected')
1129
+ }
1130
+
1131
+ export function printOpenedSim(
1132
+ openUrl: string,
1133
+ sim: BridgeSimInfo,
1134
+ mode:
1135
+ | 'desktop companion'
1136
+ | 'bridge'
1137
+ | 'direct shell open'
1138
+ | 'current sim'
1139
+ | `${string} driver`,
1140
+ quiet = false,
1141
+ ) {
1142
+ const [printableOpenUrl, printableSimUrl] = [openUrl, sim.url].map((value) => {
1143
+ if (!value) return value
1144
+ try {
1145
+ const parsed = new URL(value)
1146
+ let changed = false
1147
+ if (parsed.searchParams.has(RNX_CONFIG_QUERY_PARAM)) {
1148
+ parsed.searchParams.set(RNX_CONFIG_QUERY_PARAM, '[redacted]')
1149
+ changed = true
1150
+ }
1151
+ const bundle = parsed.searchParams.get('bundle')
1152
+ if (bundle) {
1153
+ try {
1154
+ const parsedBundle = new URL(bundle)
1155
+ if (parsedBundle.searchParams.has(RNX_CONFIG_QUERY_PARAM)) {
1156
+ parsedBundle.searchParams.set(RNX_CONFIG_QUERY_PARAM, '[redacted]')
1157
+ parsed.searchParams.set('bundle', parsedBundle.toString())
1158
+ changed = true
1159
+ }
1160
+ } catch {}
1161
+ }
1162
+ if (changed) return parsed.toString()
1163
+ } catch {}
1164
+ return value
1165
+ })
1166
+ if (quiet) {
1167
+ console.log(` Opened ConnectRN [${mode}]`)
1168
+ return
1169
+ }
1170
+ console.log(
1171
+ ` ${mode === 'current sim' ? 'loaded' : 'opened'}: ${printableOpenUrl} [${mode}]`,
1172
+ )
1173
+ // agents pipe around the JSON to grab the id; humans just want to know
1174
+ // the current sim got updated without scanning the JSON blob.
1175
+ console.log(` current sim: ${sim.id}`)
1176
+ console.log(JSON.stringify({ simId: sim.id, url: printableSimUrl }, null, 2))
1177
+ }
1178
+
1179
+ // after a successful open, agents almost always want to know what's on the
1180
+ // screen. running describe inline saves a separate roundtrip. tightly
1181
+ // bounded — a cold guest bundle takes 5-15s to fully route, so we don't
1182
+ // try to wait for that. instead: settle for a moment to let the first
1183
+ // paint land, then describe whatever's on screen. for cold loads that's
1184
+ // a splash; the caller can `rnx describe` again a second later. for
1185
+ // warm loads it's the real screen. guarded by --no-describe.
1186
+ async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: string[]) {
1187
+ if (args.includes('--no-describe')) return
1188
+ const prevQuiet = process.env.SOOTSIM_QUIET_TARGET_NOTICE
1189
+ process.env.SOOTSIM_QUIET_TARGET_NOTICE = '1'
1190
+ try {
1191
+ // brief settle, then dump the tree directly. don't go through
1192
+ // runDescribeSubcommand — its screen-transition wait can block for many
1193
+ // seconds on a still-routing guest app, which is exactly what we want
1194
+ // to skip for the open-side describe. caller can `rnx describe`
1195
+ // again later for post-routing state.
1196
+ await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 })
1197
+ const bridge = createBridge(wsPort, {
1198
+ commandTimeoutMs: 3000,
1199
+ simId,
1200
+ cliLabel: 'open --describe',
1201
+ simIdSource: 'flag',
1202
+ })
1203
+ try {
1204
+ let readyProbe: Awaited<ReturnType<typeof inspectWaitReady>> | null = null
1205
+ try {
1206
+ readyProbe = await inspectWaitReady(bridge, 1)
1207
+ } catch {}
1208
+ if (readyProbe) {
1209
+ if (!readyProbe.ready) {
1210
+ const reason = readyProbe.loadingText
1211
+ ? `still showing "${readyProbe.loadingText}"`
1212
+ : readyProbe.flag !== true
1213
+ ? 'guest ready event has not fired'
1214
+ : readyProbe.targets <= 0
1215
+ ? 'no visible app content is inspectable yet'
1216
+ : 'app tree is still stabilizing'
1217
+ console.log(
1218
+ ` app still loading: ${reason} (nodes: ${readyProbe.nodes}, targets: ${readyProbe.targets})`,
1219
+ )
1220
+ console.log(' before interacting, run: rnx wait ready --max-ms 120000')
1221
+ }
1222
+ }
1223
+ const result = await inspectDescribe(bridge, {
1224
+ describe: true,
1225
+ verbose: false,
1226
+ filter: '',
1227
+ compact: true,
1228
+ hideXy: false,
1229
+ maxDepth: 12,
1230
+ })
1231
+ if (!result?.tree) return
1232
+ console.log('')
1233
+ console.log(result.tree)
1234
+ } finally {
1235
+ bridge.close()
1236
+ }
1237
+ } catch {
1238
+ // describe is a convenience — never fail the open command on it
1239
+ } finally {
1240
+ if (prevQuiet === undefined) {
1241
+ delete process.env.SOOTSIM_QUIET_TARGET_NOTICE
1242
+ } else {
1243
+ process.env.SOOTSIM_QUIET_TARGET_NOTICE = prevQuiet
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ // poll node count and return as soon as two samples match across `stableMs`,
1249
+ // capped by `maxMs`. brief and bounded so this never blocks the open command.
1250
+ async function waitForTreeStable(
1251
+ wsPort: number,
1252
+ simId: string,
1253
+ opts: { stableMs: number; maxMs: number },
1254
+ ): Promise<void> {
1255
+ const deadline = Date.now() + opts.maxMs
1256
+ const probeCode = `(async () => (await window.__sootsimTest?.getNodeCount?.()) || 0)()`
1257
+ const bridge = createBridge(wsPort, {
1258
+ commandTimeoutMs: 2000,
1259
+ simId,
1260
+ simIdSource: 'flag',
1261
+ })
1262
+ try {
1263
+ let lastCount = -1
1264
+ let stableSince = 0
1265
+ while (Date.now() < deadline) {
1266
+ let count = -1
1267
+ try {
1268
+ const c = await bridge.send({ type: 'evaluate', code: probeCode })
1269
+ if (typeof c === 'number') count = c
1270
+ } catch {
1271
+ // transient — try again
1272
+ }
1273
+ if (count >= 0 && count === lastCount) {
1274
+ if (Date.now() - stableSince >= opts.stableMs) return
1275
+ } else {
1276
+ lastCount = count
1277
+ stableSince = Date.now()
1278
+ }
1279
+ await sleep(50)
1280
+ }
1281
+ } finally {
1282
+ bridge.close()
1283
+ }
1284
+ }
1285
+
1286
+ export async function runLinkCommand(args: string[], opts: ControlCommandOptions = {}) {
1287
+ const parsed = parseBridgeCliArgs(args, {
1288
+ port: opts.port,
1289
+ commandTimeoutMs: opts.timeoutMs,
1290
+ })
1291
+ if (parsed.positional.length !== 1) {
1292
+ console.error(' rnx open: expected exactly one <url-or-route>')
1293
+ console.error(' examples: rnx open /settings | rnx open myapp://settings')
1294
+ rnxExit(1)
1295
+ }
1296
+
1297
+ const bridge = createBridgeFromParsed(parsed)
1298
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1299
+ try {
1300
+ const target = await openAppUrl(bridge, parsed.positional[0] ?? '')
1301
+ console.log(` opened deep link: ${target}`)
1302
+ } catch (err) {
1303
+ console.error(` link failed: ${err instanceof Error ? err.message : String(err)}`)
1304
+ await printBridgeFailureDiagnostics(bridge, {
1305
+ errorsCommand: `rnx get errors 5${simHint}`,
1306
+ warningsCommand: `rnx get warnings 5${simHint}`,
1307
+ requestsCommand: `rnx get requests 5${simHint}`,
1308
+ })
1309
+ rnxExit(1)
1310
+ } finally {
1311
+ bridge.close()
1312
+ }
1313
+ }
1314
+
1315
+ export async function runOpenCommand(
1316
+ args: string[],
1317
+ opts: ControlCommandOptions = {},
1318
+ ): Promise<{ simId: string; launched: boolean } | undefined> {
1319
+ const parsed = parseBridgeCliArgs(args, {
1320
+ port: opts.port,
1321
+ commandTimeoutMs: opts.timeoutMs,
1322
+ stripBooleanFlags: [
1323
+ '--new',
1324
+ '--headless',
1325
+ '--headed',
1326
+ '--ephemeral',
1327
+ '--hot',
1328
+ '--no-hmr',
1329
+ '--no-describe',
1330
+ '--quiet',
1331
+ '--proof',
1332
+ ],
1333
+ stripValueFlags: [
1334
+ '--base-url',
1335
+ '--replace',
1336
+ '--driver',
1337
+ '--profile',
1338
+ '--cdp-port',
1339
+ '--device',
1340
+ '--viewport',
1341
+ ],
1342
+ })
1343
+ const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
1344
+ const quiet = args.includes('--quiet')
1345
+ const ephemeralProfile = args.includes('--ephemeral')
1346
+ // --cdp-port exposes Chrome's remote-debugging endpoint on the opened sim so
1347
+ // it can be cpu-profiled while bridge-driven (playwright driver only).
1348
+ const cdpPortArg = args.find((_, i) => args[i - 1] === '--cdp-port')
1349
+ const cdpPort = cdpPortArg ? Number(cdpPortArg) : undefined
1350
+ if (cdpPortArg && (!Number.isFinite(cdpPort) || cdpPort! <= 0)) {
1351
+ console.error(
1352
+ ` rnx open: --cdp-port must be a positive port number, got "${cdpPortArg}"`,
1353
+ )
1354
+ rnxExit(1)
1355
+ }
1356
+ if (requestedProfileId && ephemeralProfile) {
1357
+ console.error(' rnx open: --profile cannot be combined with --ephemeral')
1358
+ rnxExit(1)
1359
+ }
1360
+ const profileId = requestedProfileId ? ensureProfile(requestedProfileId).id : undefined
1361
+ const openInNewSim = args.includes('--new') || !!profileId || ephemeralProfile
1362
+ const commandConfig = parseOpenRuntimeConfig(args)
1363
+ if (openInNewSim && parsed.simIdSource === 'flag') {
1364
+ console.error(
1365
+ ' rnx open: --new, --profile, and --ephemeral cannot be combined with --sim',
1366
+ )
1367
+ rnxExit(1)
1368
+ }
1369
+ const target = parsed.positional[0] || ''
1370
+
1371
+ if (isAppDeepLinkTarget(target)) {
1372
+ if (openInNewSim) {
1373
+ console.error(
1374
+ ' rnx open: deep links target the current app; remove --new, --profile, or --ephemeral',
1375
+ )
1376
+ rnxExit(1)
1377
+ }
1378
+ await runLinkCommand(args, opts)
1379
+ return
1380
+ }
1381
+
1382
+ // a preview/build link is a viewing page, not a driveable sim target —
1383
+ // open it in the browser and explain rather than mis-loading it as a
1384
+ // bundle. see looksLikeSootSimShareUrl for why these can't be driven.
1385
+ if (looksLikeSootSimShareUrl(target)) {
1386
+ const isLocal = (() => {
1387
+ try {
1388
+ return isLoopbackHost(new URL(target).hostname)
1389
+ } catch {
1390
+ return false
1391
+ }
1392
+ })()
1393
+ console.log(
1394
+ ' that’s an rnx preview/build link — a full player page, not a driveable sim.',
1395
+ )
1396
+ console.log(' opening it in your browser for viewing…')
1397
+ try {
1398
+ await openUrlInBrowser(target, { background: false })
1399
+ } catch (err) {
1400
+ console.error(
1401
+ ` could not launch a browser: ${err instanceof Error ? err.message : String(err)}`,
1402
+ )
1403
+ console.error(` open it yourself: ${target}`)
1404
+ rnxExit(1)
1405
+ }
1406
+ console.log(
1407
+ isLocal
1408
+ ? ' to drive it under the CLI, point `rnx open` at the app’s dev port instead (e.g. `rnx open 8081`).'
1409
+ : ' the CLI can’t drive a remote preview (the engine only attaches to the local bridge from localhost). to drive the app, run it locally and `rnx open <port>`.',
1410
+ )
1411
+ return
1412
+ }
1413
+
1414
+ const resolvedBundle = shouldResolveBundleTargetInCli(target)
1415
+ ? await resolveConnectionInputForCli(target)
1416
+ : undefined
1417
+ const project = await resolveAppProjectForBundleUrl(resolvedBundle?.bundleUrl)
1418
+ const appConfig = await loadRNXAppConfig(project)
1419
+ const configuredRuntimeVersion = appConfig?.runtimeVersion
1420
+ const runtimeConfig = mergeRNXConfig(
1421
+ mergeRNXConfig(
1422
+ appConfig ? { ...appConfig, runtimeVersion: undefined } : undefined,
1423
+ opts.runtimeConfig,
1424
+ ),
1425
+ commandConfig,
1426
+ )
1427
+ if (configuredRuntimeVersion) {
1428
+ await ensureConfiguredRuntime(configuredRuntimeVersion)
1429
+ }
1430
+
1431
+ // keep the bridge port and the shell base URL in one world: --base-url
1432
+ // retargets polling at THAT world's bridge (otherwise open builds the page
1433
+ // against one runtime while polling the other bridge for registration and
1434
+ // times out after ~125s), and a pin nothing serves is refused rather than
1435
+ // falling back to the discovered default, which used to send
1436
+ // ensureBridgeForOpen below off to start a second shell.
1437
+ const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
1438
+ if (explicitBaseUrl) {
1439
+ const pinned = resolveBridgePortForPin({
1440
+ baseUrl: explicitBaseUrl,
1441
+ wsPort: parsed.wsPort,
1442
+ explicitPort: parsed.explicitPort,
1443
+ })
1444
+ if (pinned.error !== undefined) {
1445
+ console.error(` error: ${pinned.error}`)
1446
+ rnxExit(1)
1447
+ } else {
1448
+ parsed.wsPort = pinned.port
1449
+ }
1450
+ }
1451
+
1452
+ parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
1453
+
1454
+ // playwright is the browser-sim launcher. it owns an isolated profile and
1455
+ // can reap exactly its own browser tree. electron remains available for the
1456
+ // native desktop surface. neither path opens the user's browser profile.
1457
+ const requestedDriverId = args.find((_, i) => args[i - 1] === '--driver') || ''
1458
+ if (requestedDriverId && !isDriverId(requestedDriverId)) {
1459
+ console.error(` unknown driver "${requestedDriverId}" — run \`rnx list --drivers\``)
1460
+ rnxExit(1)
1461
+ }
1462
+ const driverId: DriverId = isDriverId(requestedDriverId)
1463
+ ? requestedDriverId
1464
+ : process.env.CI === '1' ||
1465
+ process.env.CI === 'true' ||
1466
+ process.env.GITHUB_ACTIONS === 'true' ||
1467
+ isAgentEnv() ||
1468
+ !process.stdin.isTTY
1469
+ ? 'playwright'
1470
+ : readSimulatorDriverPreference() || 'playwright'
1471
+ const driverHeadless = resolveDriverHeadless(args, driverId)
1472
+ if (cdpPort && driverId !== 'playwright') {
1473
+ console.error(' rnx open: --cdp-port is only honored by the playwright driver')
1474
+ rnxExit(1)
1475
+ }
1476
+
1477
+ // metro HMR mode. interactive opens default to hot=true so edits hot-apply
1478
+ // through rnx's HmrClient — expo CLI's createBundleUrlSearchParams
1479
+ // hardcodes hot=false on every launchAsset URL (a leftover toggle real iOS
1480
+ // sims tolerate but rnx couldn't until now), so without this flip the
1481
+ // bundle prelude doesn't include metro's HMR runtime + react-refresh and
1482
+ // edits silently no-op. `--driver playwright` (agent / CI runs) defaults
1483
+ // to hot=false for deterministic e2e. explicit `--hot` / `--no-hmr` win.
1484
+ const hotFlag = args.includes('--hot')
1485
+ const noHmrFlag = args.includes('--no-hmr')
1486
+ if (hotFlag && noHmrFlag) {
1487
+ console.error(' rnx open: --hot and --no-hmr cannot be combined')
1488
+ rnxExit(1)
1489
+ }
1490
+ const hotMode = hotFlag ? true : noHmrFlag ? false : driverId !== 'playwright'
1491
+
1492
+ // --device <model> seeds the device profile for the opened sim (stamped on
1493
+ // the engine URL, so both playwright and electron honor it). without it
1494
+ // the sim runs whatever profile the
1495
+ // shell last persisted, which silently skews browser comparisons.
1496
+ const deviceArg = args.find((_, i) => args[i - 1] === '--device')
1497
+ if (deviceArg && !(deviceArg in devices)) {
1498
+ console.error(` rnx open: unknown device "${deviceArg}"`)
1499
+ console.error(` known devices: ${listSelectableDeviceModels().join(', ')}`)
1500
+ rnxExit(1)
1501
+ }
1502
+ const deviceSpec = deviceArg ? devices[deviceArg as DeviceModel] : undefined
1503
+
1504
+ // --viewport <WxH> sizes the driver-launched page explicitly (film and
1505
+ // promo captures frame the 3d stage in landscape). takes precedence over
1506
+ // the device-derived window; without either, the driver keeps its own
1507
+ // default.
1508
+ const viewportArg = args.find((_, i) => args[i - 1] === '--viewport')
1509
+ const viewportMatch = viewportArg ? /^(\d+)x(\d+)$/.exec(viewportArg) : null
1510
+ if (viewportArg && !viewportMatch) {
1511
+ console.error(` rnx open: --viewport must be <width>x<height>, got "${viewportArg}"`)
1512
+ rnxExit(1)
1513
+ }
1514
+ const explicitViewport = viewportMatch
1515
+ ? { width: Number(viewportMatch[1]), height: Number(viewportMatch[2]) }
1516
+ : undefined
1517
+
1518
+ const buildShellUrlOpts: BuildShellUrlOptions = {
1519
+ hot: hotMode,
1520
+ device: deviceArg,
1521
+ resolvedBundle,
1522
+ project,
1523
+ proof: args.includes('--proof'),
1524
+ }
1525
+
1526
+ // resolve the shell base URL from the bridge port we settled on, so the page
1527
+ // and the polling never split across worlds.
1528
+ const selectedShellBaseUrl = configuredRuntimeVersion
1529
+ ? `http://localhost:${parsed.wsPort}/`
1530
+ : explicitBaseUrl || resolveShellBaseUrlForBridgePort(parsed.wsPort)
1531
+ const baseUrl = applyRuntimeGenerationOrigin(
1532
+ selectedShellBaseUrl,
1533
+ configuredRuntimeVersion,
1534
+ )
1535
+ const hasExplicitBaseUrl = args.includes('--base-url')
1536
+ printBridgeWorldNotice(parsed.wsPort, baseUrl)
1537
+ const savedSimId = readCurrentSimId()
1538
+ const shouldReuseCurrentSim = !openInNewSim
1539
+ // every open stamps its page URL with a one-off token and matches the sim by
1540
+ // it. a sim id alone cannot tell the page this open loaded from the page it
1541
+ // replaced, which stays registered until the browser commits the navigation.
1542
+ const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
1543
+ const simMatchesToken = (sim: BridgeSimInfo) =>
1544
+ sim.url?.includes(`inspectOpen=${token}`) ?? false
1545
+
1546
+ if (shouldReuseCurrentSim) {
1547
+ const bridge = createBridgeFromParsed(parsed)
1548
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1549
+ let fallBackToNewSim = false
1550
+ let navigatedShellUrl: string | null = null
1551
+
1552
+ try {
1553
+ let targetSim: BridgeSimInfo | null = null
1554
+ let targetShellUrl: string | null = null
1555
+ try {
1556
+ const sims = await bridge.listSims()
1557
+ if (parsed.simIdSource === 'saved') {
1558
+ targetSim =
1559
+ sims.find(
1560
+ (sim) =>
1561
+ sim.id === savedSimId &&
1562
+ sim.readyState === 'open' &&
1563
+ simUsesDriver(sim, driverId),
1564
+ ) ?? null
1565
+ if (!targetSim) {
1566
+ clearCurrentSimId()
1567
+ }
1568
+ } else if (parsed.simIdSource === 'flag') {
1569
+ targetSim = resolveTargetSim(sims, parsed.simId)
1570
+ if (!simUsesDriver(targetSim, driverId)) {
1571
+ throw new Error(
1572
+ `sim ${targetSim.id} is not managed by the ${driverId} driver`,
1573
+ )
1574
+ }
1575
+ }
1576
+ if (!targetSim && parsed.simIdSource !== 'flag') {
1577
+ targetShellUrl = await buildShellUrl(
1578
+ target,
1579
+ applyRNXConfigToUrl(baseUrl, runtimeConfig),
1580
+ buildShellUrlOpts,
1581
+ )
1582
+ const normalizedTarget = new URL(targetShellUrl)
1583
+ normalizedTarget.searchParams.delete('inspectOpen')
1584
+ targetSim =
1585
+ sims.find((sim) => {
1586
+ if (
1587
+ sim.readyState !== 'open' ||
1588
+ !sim.url ||
1589
+ !simUsesDriver(sim, driverId)
1590
+ ) {
1591
+ return false
1592
+ }
1593
+ try {
1594
+ const normalizedCurrent = new URL(sim.url)
1595
+ normalizedCurrent.searchParams.delete('inspectOpen')
1596
+ return normalizedCurrent.toString() === normalizedTarget.toString()
1597
+ } catch {
1598
+ return false
1599
+ }
1600
+ }) ?? null
1601
+ }
1602
+ if (!targetSim) {
1603
+ if (parsed.simIdSource !== 'flag') {
1604
+ fallBackToNewSim = true
1605
+ } else {
1606
+ throw new Error('no sim connected')
1607
+ }
1608
+ }
1609
+ if (!fallBackToNewSim && targetSim) {
1610
+ const resolvedBaseUrl =
1611
+ hasExplicitBaseUrl || looksLikeSootsimUrl(target)
1612
+ ? baseUrl
1613
+ : deriveCurrentSimBaseUrl(targetSim, baseUrl)
1614
+ navigatedShellUrl =
1615
+ targetShellUrl ??
1616
+ (await buildShellUrl(
1617
+ target,
1618
+ applyRNXConfigToUrl(resolvedBaseUrl, runtimeConfig),
1619
+ buildShellUrlOpts,
1620
+ ))
1621
+ const navigationUrl = new URL(navigatedShellUrl)
1622
+ navigationUrl.searchParams.set('inspectOpen', token)
1623
+ bridge
1624
+ .send({
1625
+ type: 'evaluate',
1626
+ simId: targetSim.id,
1627
+ code: `window.location.href = ${JSON.stringify(navigationUrl.toString())}`,
1628
+ })
1629
+ .catch(() => {})
1630
+ }
1631
+ } catch (err) {
1632
+ console.error(
1633
+ ` open failed: ${err instanceof Error ? err.message : String(err)}`,
1634
+ )
1635
+ await printBridgeFailureDiagnostics(bridge, {
1636
+ errorsCommand: `rnx get errors 5${simHint}`,
1637
+ warningsCommand: `rnx get warnings 5${simHint}`,
1638
+ requestsCommand: `rnx get requests 5${simHint}`,
1639
+ })
1640
+ rnxExit(1)
1641
+ }
1642
+
1643
+ if (!fallBackToNewSim && targetSim && navigatedShellUrl) {
1644
+ // the navigated page may keep the sim id (restored from session
1645
+ // storage) or register a new one, so match by token, never by id.
1646
+ const navigatedSim = await waitForSimMatch(
1647
+ parsed.wsPort,
1648
+ parsed.commandTimeoutMs,
1649
+ (sim) =>
1650
+ sim.readyState === 'open' &&
1651
+ simUsesDriver(sim, driverId) &&
1652
+ simMatchesToken(sim),
1653
+ { attempts: Math.round(30 * flowTimeoutScale()) },
1654
+ )
1655
+ if (!navigatedSim) {
1656
+ console.error(
1657
+ ` timed out waiting for sim ${targetSim.id} to load ${navigatedShellUrl}`,
1658
+ )
1659
+ await printBridgeFailureDiagnostics(bridge, {
1660
+ errorsCommand: `rnx get errors 5${simHint}`,
1661
+ warningsCommand: `rnx get warnings 5${simHint}`,
1662
+ requestsCommand: `rnx get requests 5${simHint}`,
1663
+ })
1664
+ rnxExit(1)
1665
+ }
1666
+ targetSim = navigatedSim
1667
+ const ready = await waitForSimReady(
1668
+ parsed.wsPort,
1669
+ parsed.commandTimeoutMs,
1670
+ targetSim.id,
1671
+ )
1672
+ if (!ready.bridge) {
1673
+ const observed = ready.lastError
1674
+ ? `last bridge error: ${ready.lastError}`
1675
+ : ready.lastCount === null
1676
+ ? 'guest never installed window.__sootsimTest'
1677
+ : `last node count: ${String(ready.lastCount)} (needs > ${ready.minNodeCount})`
1678
+ console.error(
1679
+ ` timed out waiting for current sim to load target after ${ready.attempts} polls over ${Math.round((ready.attempts * ready.intervalMs) / 1000)}s; ${observed}`,
1680
+ )
1681
+ await printBridgeFailureDiagnostics(bridge, {
1682
+ errorsCommand: `rnx get errors 5${simHint}`,
1683
+ warningsCommand: `rnx get warnings 5${simHint}`,
1684
+ requestsCommand: `rnx get requests 5${simHint}`,
1685
+ })
1686
+ rnxExit(1)
1687
+ }
1688
+ ready.bridge.close()
1689
+ saveCurrentSimId(targetSim.id)
1690
+ printOpenedSim(
1691
+ navigatedShellUrl,
1692
+ { ...targetSim, url: navigatedShellUrl },
1693
+ 'current sim',
1694
+ quiet,
1695
+ )
1696
+ await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
1697
+ return { simId: targetSim.id, launched: false }
1698
+ }
1699
+ } finally {
1700
+ bridge.close()
1701
+ }
1702
+ }
1703
+
1704
+ const configuredBaseUrl = applyRNXConfigToUrl(baseUrl, runtimeConfig)
1705
+ const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts)
1706
+ const ownerPid = getStableOwnerPid()
1707
+ if (!ownerPid) {
1708
+ console.error(
1709
+ ' could not resolve a stable owning process; refusing unsupervised sim host',
1710
+ )
1711
+ rnxExit(1)
1712
+ }
1713
+ const driver = getDriver(driverId)
1714
+ if (!driver) {
1715
+ console.error(` unknown driver "${driverId}" — run \`rnx list --drivers\``)
1716
+ rnxExit(1)
1717
+ }
1718
+ const connectWait = resolveDriverConnectWaitOptions()
1719
+ const result = await driver.launch({
1720
+ url: openUrl,
1721
+ // the shell serves the sim page but its WS bridge may have drifted off
1722
+ // the shell-port-implied default. inject the bridge this command settled
1723
+ // on so the page does not reconnect to a stale world. re-deriving it from
1724
+ // the shell URL could name a third port, since a bridge with no lockfile
1725
+ // resolves back to the default shell URL and that shell may belong to a
1726
+ // live world of its own.
1727
+ bridgePort: parsed.wsPort,
1728
+ headless: driverHeadless,
1729
+ profileId,
1730
+ ephemeralProfile,
1731
+ cdpPort,
1732
+ device: deviceArg,
1733
+ ownerPid,
1734
+ viewport:
1735
+ explicitViewport ??
1736
+ (deviceSpec
1737
+ ? { width: deviceSpec.width + 120, height: deviceSpec.height + 60 }
1738
+ : undefined),
1739
+ connectTimeoutMs: connectWait.hostTimeoutMs,
1740
+ })
1741
+ if (!result.launched) {
1742
+ console.error(` ${driver.name} driver: ${result.message}`)
1743
+ rnxExit(1)
1744
+ }
1745
+ const outcome = await waitForSimMatchOrHostExit(
1746
+ parsed.wsPort,
1747
+ parsed.commandTimeoutMs,
1748
+ simMatchesToken,
1749
+ result.pid,
1750
+ { attempts: connectWait.attempts, intervalMs: connectWait.intervalMs },
1751
+ )
1752
+ if (outcome.kind === 'host-exit') {
1753
+ printDriverDiagnosticLogTail(result)
1754
+ console.error(` ${driver.name} host crashed — sim is no longer attached`)
1755
+ rnxExit(1)
1756
+ }
1757
+ if (outcome.kind === 'timeout') {
1758
+ if (result.pid) {
1759
+ try {
1760
+ process.kill(result.pid, 'SIGTERM')
1761
+ console.error(` closed ${driver.name} host process ${result.pid}`)
1762
+ } catch {}
1763
+ }
1764
+ printDriverDiagnosticLogTail(result)
1765
+ console.error(
1766
+ ` timed out after ${connectWait.timeoutMs}ms waiting for opened sim to connect`,
1767
+ )
1768
+ rnxExit(1)
1769
+ }
1770
+ const match = outcome.sim
1771
+ saveCurrentSimId(match.id)
1772
+ signalDriverLaunchConnected(result)
1773
+ printOpenedSim(openUrl, match, `${driver.name} driver`, quiet)
1774
+ await maybeDescribeAfterOpen(parsed.wsPort, match.id, args)
1775
+ return { simId: match.id, launched: true }
1776
+ }
1777
+
1778
+ async function runUseLikeCommand(args: string[], opts: ControlCommandOptions = {}) {
1779
+ const parsed = parseBridgeCliArgs(args, {
1780
+ port: opts.port,
1781
+ commandTimeoutMs: opts.timeoutMs,
1782
+ stripBooleanFlags: ['--force'],
1783
+ })
1784
+ const force = args.includes('--force')
1785
+ const bridge = createBridgeFromParsed(parsed)
1786
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1787
+
1788
+ try {
1789
+ try {
1790
+ const sims = await bridge.listSims()
1791
+ const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
1792
+ // `use`/`focus` retarget the CLI at a sim — silently grabbing one a
1793
+ // human is actively driving is the same footgun `claim` guards against,
1794
+ // so honor the lock here too (this command historically did not, which
1795
+ // let `rnx use <id>` steal the user's live tab).
1796
+ if (target.lockedBy && target.lockExpiresAt && target.lockExpiresAt > Date.now()) {
1797
+ const secs = Math.max(0, Math.round((target.lockExpiresAt - Date.now()) / 1000))
1798
+ if (target.lockedByKind === 'user-active') {
1799
+ // the active human is interacting with this tab. never steal it,
1800
+ // even with --force — mirrors `claim`, which refuses to even
1801
+ // report a user-active takeover. open a fresh sim instead.
1802
+ console.error(
1803
+ ` refused: ${target.id} is locked by the active user (${secs}s) — that's a live human tab`,
1804
+ )
1805
+ console.error(` run \`rnx open --new\` for a fresh investigation sim`)
1806
+ rnxExit(1)
1807
+ }
1808
+ if (!force) {
1809
+ // another CLI agent leases it. don't hijack its target silently;
1810
+ // a cli lease is short (60s, refreshes per command) so this is
1811
+ // usually a real conflict, not a stale lock.
1812
+ console.error(
1813
+ ` refused: ${target.id} is leased by ${target.lockedBy} (${secs}s)`,
1814
+ )
1815
+ console.error(
1816
+ ` \`rnx use ${target.id} --force\` to take it, or \`rnx open --new\` for a fresh sim`,
1817
+ )
1818
+ rnxExit(1)
1819
+ }
1820
+ }
1821
+ await bridge.focusSim(target.id)
1822
+ saveCurrentSimId(target.id)
1823
+ console.log(` using: ${target.id}`)
1824
+ } catch (err) {
1825
+ rethrowIfExit(err)
1826
+ console.error(` use failed: ${err instanceof Error ? err.message : String(err)}`)
1827
+ await printBridgeFailureDiagnostics(bridge, {
1828
+ errorsCommand: `rnx get errors 5${simHint}`,
1829
+ warningsCommand: `rnx get warnings 5${simHint}`,
1830
+ requestsCommand: `rnx get requests 5${simHint}`,
1831
+ })
1832
+ rnxExit(1)
1833
+ }
1834
+ } finally {
1835
+ bridge.close()
1836
+ }
1837
+ }
1838
+
1839
+ export async function runUseCommand(args: string[], opts: ControlCommandOptions = {}) {
1840
+ await runUseLikeCommand(args, opts)
1841
+ }
1842
+
1843
+ export async function runFocusCommand(args: string[], opts: ControlCommandOptions = {}) {
1844
+ await runUseLikeCommand(args, opts)
1845
+ }
1846
+
1847
+ export async function runClaimCommand(args: string[], opts: ControlCommandOptions = {}) {
1848
+ const parsed = parseBridgeCliArgs(args, {
1849
+ port: opts.port,
1850
+ commandTimeoutMs: opts.timeoutMs,
1851
+ stripBooleanFlags: ['--force'],
1852
+ })
1853
+ const force = args.includes('--force')
1854
+ const bridge = createBridgeFromParsed(parsed)
1855
+
1856
+ try {
1857
+ try {
1858
+ const sims = await bridge.listSims()
1859
+ const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
1860
+ const priorHolder =
1861
+ force && target.lockedBy && target.lockedByKind !== 'user-active'
1862
+ ? target.lockedBy
1863
+ : null
1864
+ const result = await bridge.claim(target.id, { force })
1865
+ saveCurrentSimId(target.id)
1866
+ const ttl = Math.max(0, Math.round((result.lockExpiresAt - Date.now()) / 1000))
1867
+ const booted = result.bootedCount > 0 ? ` (booted ${result.bootedCount})` : ''
1868
+ console.log(` claimed: ${result.simId} [${ttl}s]${booted}`)
1869
+ if (priorHolder) {
1870
+ console.log(` took over from: ${priorHolder}`)
1871
+ }
1872
+ } catch (err) {
1873
+ if (err instanceof BridgeSimLockedError) {
1874
+ const secs = Math.max(0, Math.round(err.lock.expiresInMs / 1000))
1875
+ console.error(` claim failed: locked by ${err.lock.by} for ${secs}s more`)
1876
+ console.error(` use --force to take it, or \`rnx open --new\` for a fresh sim`)
1877
+ rnxExit(1)
1878
+ }
1879
+ console.error(` claim failed: ${err instanceof Error ? err.message : String(err)}`)
1880
+ rnxExit(1)
1881
+ }
1882
+ } finally {
1883
+ bridge.close()
1884
+ }
1885
+ }
1886
+
1887
+ // close many sims at once. fires every close, then polls listSims until the
1888
+ // whole set is gone or the timeout elapses — far faster than awaiting
1889
+ // waitForSimGone serially when clearing dozens of leaked sims.
1890
+ export async function closeSimsBulk(
1891
+ bridge: ReturnType<typeof createBridgeFromParsed>,
1892
+ wsPort: number,
1893
+ commandTimeoutMs: number,
1894
+ ids: string[],
1895
+ ): Promise<{ closed: string[]; remaining: string[] }> {
1896
+ if (ids.length === 0) return { closed: [], remaining: [] }
1897
+ await Promise.all(ids.map((id) => bridge.closeSim(id).catch(() => {})))
1898
+ const wanted = new Set(ids)
1899
+ for (let i = 0; i < 40; i++) {
1900
+ let stillOpen: string[] = []
1901
+ try {
1902
+ const probe = createBridge(wsPort, { commandTimeoutMs })
1903
+ try {
1904
+ const sims = await probe.listSims()
1905
+ stillOpen = sims
1906
+ .filter((s) => wanted.has(s.id) && s.readyState === 'open')
1907
+ .map((s) => s.id)
1908
+ } finally {
1909
+ probe.close()
1910
+ }
1911
+ } catch {
1912
+ stillOpen = []
1913
+ }
1914
+ if (stillOpen.length === 0) {
1915
+ return { closed: [...wanted], remaining: [] }
1916
+ }
1917
+ if (i === 39) {
1918
+ return {
1919
+ closed: [...wanted].filter((id) => !stillOpen.includes(id)),
1920
+ remaining: stillOpen,
1921
+ }
1922
+ }
1923
+ await sleep(250)
1924
+ }
1925
+ return { closed: [...wanted], remaining: [] }
1926
+ }
1927
+
1928
+ export interface BulkCloseRefusal {
1929
+ id: string
1930
+ lockedBy: string
1931
+ kind: 'cli' | 'user-active'
1932
+ expiresInMs: number
1933
+ }
1934
+
1935
+ export interface BulkClosePlan {
1936
+ openIds: string[]
1937
+ keepId: string | null
1938
+ targets: string[]
1939
+ staleSavedId: string | null
1940
+ missingExplicitKeepId: string | null
1941
+ fallbackKeepId: string | null
1942
+ refused: BulkCloseRefusal[]
1943
+ }
1944
+
1945
+ export function planBulkCloseTargets(
1946
+ sims: BridgeSimInfo[],
1947
+ opts: {
1948
+ closeOthers: boolean
1949
+ explicitKeepId?: string
1950
+ savedKeepId?: string | null
1951
+ force?: boolean
1952
+ now?: number
1953
+ },
1954
+ ): BulkClosePlan {
1955
+ const open = sims.filter((sim) => sim.readyState === 'open')
1956
+ const openIds = open.map((sim) => sim.id)
1957
+ const explicitKeepId = opts.explicitKeepId?.trim() || ''
1958
+ const savedKeepId = opts.savedKeepId?.trim() || ''
1959
+ const savedIsOpen = savedKeepId ? open.some((sim) => sim.id === savedKeepId) : false
1960
+ const staleSavedId = savedKeepId && !savedIsOpen ? savedKeepId : null
1961
+ let keepId: string | null = null
1962
+ let missingExplicitKeepId: string | null = null
1963
+ let fallbackKeepId: string | null = null
1964
+
1965
+ if (opts.closeOthers) {
1966
+ if (explicitKeepId) {
1967
+ const match = open.find((sim) => sim.id === explicitKeepId)
1968
+ if (match) {
1969
+ keepId = match.id
1970
+ } else {
1971
+ missingExplicitKeepId = explicitKeepId
1972
+ }
1973
+ } else if (savedKeepId && savedIsOpen) {
1974
+ keepId = savedKeepId
1975
+ } else {
1976
+ const fallback = open.find((sim) => sim.isPrimary) ?? open[0] ?? null
1977
+ if (fallback) {
1978
+ keepId = fallback.id
1979
+ if (savedKeepId && !savedIsOpen) fallbackKeepId = fallback.id
1980
+ }
1981
+ }
1982
+ }
1983
+
1984
+ // a sim another agent holds is not ours to close. `use`/`focus`/`claim` all
1985
+ // refuse a live lease; bulk close historically did not look at leases at
1986
+ // all, so `close --all` silently killed other agents' sims mid-capture.
1987
+ const now = opts.now ?? Date.now()
1988
+ const refused: BulkCloseRefusal[] = []
1989
+ for (const sim of open) {
1990
+ if (sim.id === keepId) continue
1991
+ const lockedBy = sim.lockedBy
1992
+ const expiresAt = sim.lockExpiresAt
1993
+ if (!lockedBy || !expiresAt || expiresAt <= now || sim.lockedByMe) continue
1994
+ const kind = sim.lockedByKind ?? 'cli'
1995
+ // --force takes over another agent's cli lease, the same escape hatch
1996
+ // `use --force` offers. a `user-active` lease is a live human tab and is
1997
+ // never closable, matching `claim`, which refuses to report a takeover.
1998
+ if (kind === 'cli' && opts.force) continue
1999
+ refused.push({
2000
+ id: sim.id,
2001
+ lockedBy,
2002
+ kind,
2003
+ expiresInMs: Math.max(0, expiresAt - now),
2004
+ })
2005
+ }
2006
+ const refusedIds = new Set(refused.map((entry) => entry.id))
2007
+
2008
+ return {
2009
+ openIds,
2010
+ keepId,
2011
+ targets: missingExplicitKeepId
2012
+ ? []
2013
+ : openIds.filter((id) => id !== keepId && !refusedIds.has(id)),
2014
+ staleSavedId,
2015
+ missingExplicitKeepId,
2016
+ fallbackKeepId,
2017
+ refused,
2018
+ }
2019
+ }
2020
+
2021
+ export async function runCloseCommand(args: string[], opts: ControlCommandOptions = {}) {
2022
+ const parsed = parseBridgeCliArgs(args, {
2023
+ port: opts.port,
2024
+ commandTimeoutMs: opts.timeoutMs,
2025
+ stripBooleanFlags: ['--all', '--others', '--force'],
2026
+ })
2027
+ const bridge = createBridgeFromParsed(parsed)
2028
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
2029
+ const closeAll = args.includes('--all')
2030
+ const closeOthers = args.includes('--others')
2031
+ const force = args.includes('--force')
2032
+
2033
+ if (closeAll || closeOthers) {
2034
+ try {
2035
+ const sims = await bridge.listSims()
2036
+ const savedId = readCurrentSimId()
2037
+ const explicitKeepId =
2038
+ parsed.positional[0] || (parsed.simIdSource === 'flag' ? parsed.simId : undefined)
2039
+ // `--others` keeps the explicitly-targeted sim, else the saved/current
2040
+ // sim, else primary. if an explicit keep target is gone, abort instead
2041
+ // of treating "keep nothing" as permission to close every open sim.
2042
+ const plan = planBulkCloseTargets(sims, {
2043
+ closeOthers,
2044
+ explicitKeepId,
2045
+ savedKeepId: savedId,
2046
+ force,
2047
+ })
2048
+ for (const entry of plan.refused) {
2049
+ const secs = Math.round(entry.expiresInMs / 1000)
2050
+ console.error(
2051
+ entry.kind === 'user-active'
2052
+ ? ` skipped: ${entry.id} is locked by the active user (${secs}s) — that's a live human tab`
2053
+ : ` skipped: ${entry.id} is leased by ${formatLockOwner(entry.lockedBy)} (${secs}s)`,
2054
+ )
2055
+ }
2056
+ if (plan.refused.some((entry) => entry.kind === 'cli') && !force) {
2057
+ console.error(` \`rnx close --all --force\` to close leased sims too`)
2058
+ }
2059
+ if (plan.staleSavedId) {
2060
+ clearCurrentSimId()
2061
+ }
2062
+ if (plan.fallbackKeepId) {
2063
+ saveCurrentSimId(plan.fallbackKeepId)
2064
+ console.log(
2065
+ ` saved sim ${plan.staleSavedId} is gone — keeping primary ${plan.fallbackKeepId}`,
2066
+ )
2067
+ }
2068
+ if (plan.missingExplicitKeepId) {
2069
+ console.error(
2070
+ ` close failed: keep sim ${plan.missingExplicitKeepId} is not connected; not closing other sims`,
2071
+ )
2072
+ rnxExit(1)
2073
+ }
2074
+ if (plan.targets.length === 0) {
2075
+ console.log(
2076
+ plan.keepId
2077
+ ? ` nothing to close — only the kept sim ${plan.keepId} is connected`
2078
+ : plan.refused.length > 0
2079
+ ? ` nothing to close — every other connected sim is leased by someone else`
2080
+ : plan.staleSavedId
2081
+ ? ` nothing to close — no sims connected (cleared stale current sim ${plan.staleSavedId})`
2082
+ : ' nothing to close — no sims connected',
2083
+ )
2084
+ return
2085
+ }
2086
+ const result = await closeSimsBulk(
2087
+ bridge,
2088
+ parsed.wsPort,
2089
+ parsed.commandTimeoutMs,
2090
+ plan.targets,
2091
+ )
2092
+ await terminatePlaywrightHostsForSims(sims, plan.targets)
2093
+ // saved current sim may have just been closed — repoint or clear it.
2094
+ const nextSavedId = readCurrentSimId()
2095
+ if (nextSavedId && result.closed.includes(nextSavedId)) {
2096
+ if (plan.keepId) saveCurrentSimId(plan.keepId)
2097
+ else clearCurrentSimId()
2098
+ }
2099
+ const summary = ` closed ${result.closed.length} sim(s)${
2100
+ plan.keepId ? ` (kept ${plan.keepId})` : ''
2101
+ }`
2102
+ console.log(summary)
2103
+ if (result.remaining.length > 0) {
2104
+ console.error(` close failed: still connected: ${result.remaining.join(', ')}`)
2105
+ rnxExit(1)
2106
+ }
2107
+ } catch (err) {
2108
+ rethrowIfExit(err)
2109
+ console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
2110
+ rnxExit(1)
2111
+ } finally {
2112
+ bridge.close()
2113
+ }
2114
+ return
2115
+ }
2116
+
2117
+ try {
2118
+ try {
2119
+ const sims = await bridge.listSims()
2120
+ const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
2121
+ const fallbackTarget = sims.find(
2122
+ (sim) => sim.id !== target.id && sim.readyState === 'open',
2123
+ )
2124
+ await bridge.closeSim(target.id)
2125
+ const closed = await waitForSimGone(
2126
+ parsed.wsPort,
2127
+ parsed.commandTimeoutMs,
2128
+ target.id,
2129
+ )
2130
+ if (!closed) {
2131
+ await terminatePlaywrightHostsForSims(sims, [target.id])
2132
+ console.error(` close failed: ${target.id} is still connected`)
2133
+ rnxExit(1)
2134
+ }
2135
+ await terminatePlaywrightHostsForSims(sims, [target.id])
2136
+ if (readCurrentSimId() === target.id) {
2137
+ if (fallbackTarget) saveCurrentSimId(fallbackTarget.id)
2138
+ else clearCurrentSimId()
2139
+ }
2140
+ console.log(` closed: ${target.id}`)
2141
+ } catch (err) {
2142
+ rethrowIfExit(err)
2143
+ console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
2144
+ await printBridgeFailureDiagnostics(bridge, {
2145
+ errorsCommand: `rnx get errors 5${simHint}`,
2146
+ warningsCommand: `rnx get warnings 5${simHint}`,
2147
+ requestsCommand: `rnx get requests 5${simHint}`,
2148
+ })
2149
+ rnxExit(1)
2150
+ }
2151
+ } finally {
2152
+ bridge.close()
2153
+ }
2154
+ }