rnxsim 0.1.313 → 0.1.314

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 (297) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
  128. package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
  129. package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
  130. package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
  131. package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
  132. package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
  133. package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
  134. package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
  135. package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
  136. package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
  137. package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
  138. package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
  139. package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
  140. package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
  141. package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
  142. package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
  143. package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
  144. package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
  145. package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
  146. package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
  147. package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
  148. package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
  149. package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
  150. package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
  151. package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
  152. package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
  153. package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
  154. package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
  155. package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
  156. package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
  157. package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
  158. package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
  159. package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
  160. package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
  161. package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
  162. package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
  163. package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
  164. package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
  165. package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
  166. package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
  167. package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
  168. package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
  169. package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
  170. package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
  171. package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
  172. package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
  173. package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
  174. package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
  175. package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
  176. package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
  177. package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-WGGRJDRE.js} +1 -1
  178. package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
  179. package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
  180. package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
  181. package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
  182. package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
  183. package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
  184. package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
  185. package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
  186. package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
  187. package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
  188. package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
  189. package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
  190. package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
  191. package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
  192. package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
  193. package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
  196. package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
  197. package/dist-cli/chunks/{film-BJGTBYZB.js → film-GDMR33VO.js} +3 -3
  198. package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
  199. package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
  201. package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
  202. package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
  203. package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
  204. package/dist-cli/chunks/{login-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
  205. package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
  206. package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
  208. package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
  210. package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
  211. package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
  212. package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-TAI6DYZO.js} +2 -2
  214. package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
  215. package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
  219. package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
  220. package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
  221. package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
  222. package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
  223. package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-OYC57C4X.js} +6 -6
  224. package/dist-cli/chunks/store-32HSPZHI.js +4 -0
  225. package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
  226. package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-OEFNZRIP.js} +2 -2
  228. package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
  229. package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
  230. package/dist-cli/chunks/{web-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-YNHRFUDX.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +480 -280
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
  277. package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
  278. package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
  279. package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
  280. package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
  281. package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
  282. package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
  283. package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
  284. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
  285. package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
  286. package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
  287. package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
  288. package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
  289. package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
  290. package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
  291. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
  292. package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
  293. package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
  294. package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
  295. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
  296. package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
  297. package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
@@ -0,0 +1,798 @@
1
+ // sootsim CLI ↔ sim WS bridge
2
+ //
3
+ // the sootsim runtime opens a WebSocket server (default port 7668) that
4
+ // accepts JSON commands like `{ type: 'evaluate', code: 'window.__sootsimTest.getNodeCount()' }`
5
+ // or `{ type: 'call', path: 'SootSim.bridges.mainShell.launchApp', args: ['photos'] }`
6
+ // and responds with `{ id, result }` or `{ id, error }`. CLI commands use
7
+ // this to drive a running sootsim instance from the terminal.
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { WebSocket } from 'ws'
12
+ import {
13
+ DEFAULT_SOOTSIM_BRIDGE_PORT,
14
+ resolveSootsimBridgePort,
15
+ } from '../src/bridge-constants'
16
+ import { DEFAULT_SOOTSIM_SHELL_URL } from '../src/cli-constants'
17
+ import {
18
+ isDaemonLockfileFresh,
19
+ isDevBridgeLockfileFresh,
20
+ readDaemonLockfile,
21
+ readDevBridgeLockfiles,
22
+ sootsimHomeDir,
23
+ type DevBridgeLockfile,
24
+ } from '../src/home-paths'
25
+ import { clearCurrentSimId, getCliIdentity, readCurrentSimId } from './current-sim'
26
+
27
+ // --- bridge world resolution ---------------------------------------------
28
+ //
29
+ // two bridge kinds can coexist on one machine: Vite dev-shell bridges
30
+ // (~/.sootsim/dev-bridges/<port>.json, shells served by Vite) and the installed
31
+ // daemon runtime (~/.sootsim/daemon.json, shell served by the daemon's own
32
+ // http server). every default — the ws port a command connects to AND the
33
+ // shell base URL `open` builds its page from — must come from the SAME
34
+ // world, or `open` polls one bridge while the sim registers on the other
35
+ // (the 125s-timeout split-brain bug). resolveBridgeWorld is the single
36
+ // source of that decision: an invocation's explicit offset wins, then its
37
+ // checkout's dev bridge, then the canonical dev bridge, a fresh daemon, and
38
+ // finally hardcoded defaults.
39
+
40
+ export interface BridgeWorld {
41
+ source: 'dev-bridge' | 'daemon' | 'default'
42
+ bridgePort: number
43
+ shellBaseUrl: string
44
+ }
45
+
46
+ // resolved once per CLI process so a multi-step command keeps one world for
47
+ // its entire invocation. separate CLI calls make the same deterministic choice
48
+ // from the owner-scoped dev records instead of following whichever heartbeat
49
+ // happened to run last.
50
+ let resolvedWorld: { homeDir: string; world: BridgeWorld } | null = null
51
+
52
+ function liveDevBridgeLockfiles(): DevBridgeLockfile[] {
53
+ return readDevBridgeLockfiles().filter((lockfile) => isDevBridgeLockfileFresh(lockfile))
54
+ }
55
+
56
+ function checkoutRoot(cwd: string): string | null {
57
+ let current = path.resolve(cwd)
58
+ while (true) {
59
+ if (fs.existsSync(path.join(current, '.git'))) return current
60
+ const parent = path.dirname(current)
61
+ if (parent === current) return null
62
+ current = parent
63
+ }
64
+ }
65
+
66
+ function pathContains(parent: string, child: string): boolean {
67
+ const relative = path.relative(parent, child)
68
+ return (
69
+ relative === '' ||
70
+ (relative !== '..' &&
71
+ !relative.startsWith(`..${path.sep}`) &&
72
+ !path.isAbsolute(relative))
73
+ )
74
+ }
75
+
76
+ function resolveDefaultDevBridgeLockfile(): DevBridgeLockfile | null {
77
+ const lockfiles = liveDevBridgeLockfiles()
78
+ if (lockfiles.length === 0) return null
79
+
80
+ if (
81
+ process.env.SOOTSIM_BRIDGE_PORT !== undefined ||
82
+ process.env.PORT_OFFSET !== undefined
83
+ ) {
84
+ const requestedPort = resolveSootsimBridgePort({
85
+ explicitPort: process.env.SOOTSIM_BRIDGE_PORT,
86
+ portOffset: process.env.PORT_OFFSET,
87
+ })
88
+ const requested = lockfiles.find((lockfile) => lockfile.bridgePort === requestedPort)
89
+ if (requested) return requested
90
+ }
91
+
92
+ let cwd: string | null = null
93
+ try {
94
+ cwd = process.cwd()
95
+ } catch {}
96
+ if (cwd) {
97
+ const currentCheckout = checkoutRoot(cwd)
98
+ const sameCheckout = lockfiles.filter((lockfile) => {
99
+ const bridgeCheckout = checkoutRoot(lockfile.cwd)
100
+ if (currentCheckout && bridgeCheckout) return currentCheckout === bridgeCheckout
101
+ return pathContains(cwd, lockfile.cwd) || pathContains(lockfile.cwd, cwd)
102
+ })
103
+ if (sameCheckout.length > 0) {
104
+ return (
105
+ sameCheckout.find(
106
+ (lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT,
107
+ ) ?? sameCheckout[0]
108
+ )
109
+ }
110
+ }
111
+
112
+ return (
113
+ lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ??
114
+ lockfiles[0]
115
+ )
116
+ }
117
+
118
+ export function resolveBridgeWorld(): BridgeWorld {
119
+ const homeDir = sootsimHomeDir()
120
+ if (resolvedWorld?.homeDir === homeDir) return resolvedWorld.world
121
+ const world = resolveBridgeWorldFresh()
122
+ resolvedWorld = { homeDir, world }
123
+ return world
124
+ }
125
+
126
+ function resolveBridgeWorldFresh(): BridgeWorld {
127
+ const dev = resolveDefaultDevBridgeLockfile()
128
+ if (dev) {
129
+ return {
130
+ source: 'dev-bridge',
131
+ bridgePort: dev.bridgePort,
132
+ // older lockfile writers don't record the vite http port; the default
133
+ // shell URL is the dev shell anyway, so the fallback stays coherent.
134
+ shellBaseUrl: dev.shellPort
135
+ ? `http://localhost:${dev.shellPort}/`
136
+ : DEFAULT_SOOTSIM_SHELL_URL,
137
+ }
138
+ }
139
+ const lock = readDaemonLockfile()
140
+ if (lock && isDaemonLockfileFresh(lock)) {
141
+ return {
142
+ source: 'daemon',
143
+ bridgePort: lock.bridgePort,
144
+ shellBaseUrl:
145
+ lock.runtimePort > 0
146
+ ? `http://localhost:${lock.runtimePort}/`
147
+ : DEFAULT_SOOTSIM_SHELL_URL,
148
+ }
149
+ }
150
+ return {
151
+ source: 'default',
152
+ bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT,
153
+ shellBaseUrl: DEFAULT_SOOTSIM_SHELL_URL,
154
+ }
155
+ }
156
+
157
+ /** the world a specific bridge port belongs to, or null when no fresh
158
+ * lockfile claims it. lets `open --port 7669` derive the daemon's shell
159
+ * base URL instead of mixing worlds. */
160
+ export function worldForBridgePort(
161
+ port: number,
162
+ ): { source: 'dev-bridge' | 'daemon'; shellBaseUrl: string } | null {
163
+ const dev = liveDevBridgeLockfiles().find((lockfile) => lockfile.bridgePort === port)
164
+ if (dev) {
165
+ return {
166
+ source: 'dev-bridge',
167
+ shellBaseUrl: dev.shellPort
168
+ ? `http://localhost:${dev.shellPort}/`
169
+ : DEFAULT_SOOTSIM_SHELL_URL,
170
+ }
171
+ }
172
+ const lock = readDaemonLockfile()
173
+ if (lock && isDaemonLockfileFresh(lock) && lock.bridgePort === port) {
174
+ return {
175
+ source: 'daemon',
176
+ shellBaseUrl:
177
+ lock.runtimePort > 0
178
+ ? `http://localhost:${lock.runtimePort}/`
179
+ : DEFAULT_SOOTSIM_SHELL_URL,
180
+ }
181
+ }
182
+ return null
183
+ }
184
+
185
+ /** the bridge port of the world that serves a given shell base URL, or null
186
+ * when no fresh lockfile claims it. lets `open --base-url` (without --port)
187
+ * poll the bridge that actually belongs to that shell. */
188
+ export function bridgePortForShellBaseUrl(baseUrl: string): number | null {
189
+ let port: number
190
+ try {
191
+ const url = new URL(baseUrl)
192
+ port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
193
+ } catch {
194
+ return null
195
+ }
196
+ if (!Number.isFinite(port) || port <= 0) return null
197
+ const dev = liveDevBridgeLockfiles().find(
198
+ (lockfile) =>
199
+ lockfile.shellPort === port ||
200
+ lockfile.runtimePort === port ||
201
+ lockfile.bridgePort === port,
202
+ )
203
+ if (dev) {
204
+ return dev.bridgePort
205
+ }
206
+ const lock = readDaemonLockfile()
207
+ if (
208
+ lock &&
209
+ isDaemonLockfileFresh(lock) &&
210
+ (lock.runtimePort === port || lock.bridgePort === port)
211
+ ) {
212
+ return lock.bridgePort
213
+ }
214
+ return null
215
+ }
216
+
217
+ /** default ws bridge port for CLI commands: the fresh dev bridge when a vite
218
+ * shell is live, else the fresh daemon, else the hardcoded default. one
219
+ * deterministic answer regardless of how the CLI was invoked (global binary
220
+ * vs repo checkout) — invocation-path heuristics made `sootsim list` flap
221
+ * between worlds. */
222
+ export function resolveDefaultBridgePort(): number {
223
+ return resolveBridgeWorld().bridgePort
224
+ }
225
+
226
+ /** one-line stderr notice naming the bridge world a command resolved, so the
227
+ * dev-bridge vs daemon pick is never silent (the split-brain bug hid behind
228
+ * exactly this silence). pass the shell base URL actually used when the
229
+ * caller resolved one (open); otherwise the port's own world URL prints. */
230
+ export function printBridgeWorldNotice(
231
+ bridgePort: number,
232
+ shellBaseUrl?: string | null,
233
+ ): void {
234
+ const world = worldForBridgePort(bridgePort)
235
+ const kind =
236
+ world?.source === 'dev-bridge'
237
+ ? 'dev bridge'
238
+ : world?.source === 'daemon'
239
+ ? 'daemon bridge'
240
+ : 'bridge'
241
+ const origin =
242
+ world?.source === 'dev-bridge'
243
+ ? `dev-bridges/${bridgePort}.json`
244
+ : world?.source === 'daemon'
245
+ ? 'daemon.json'
246
+ : 'no lockfile'
247
+ const shell = shellBaseUrl ?? world?.shellBaseUrl
248
+ process.stderr.write(
249
+ ` → ${kind} :${bridgePort} (${shell ? `shell ${shell}, ` : ''}${origin})\n`,
250
+ )
251
+ }
252
+
253
+ export interface WsBridge {
254
+ // `opts.timeoutMs` overrides the bridge-wide commandTimeoutMs for a single
255
+ // call — used for fast readiness probes so a dead/unresponsive sim fails in
256
+ // seconds instead of stalling the full command timeout.
257
+ send(
258
+ cmd: { type: string; [k: string]: any },
259
+ opts?: { timeoutMs?: number },
260
+ ): Promise<any>
261
+ listSims(): Promise<BridgeSimInfo[]>
262
+ focusSim(simId?: string): Promise<any>
263
+ closeSim(simId?: string): Promise<any>
264
+ claim(simId?: string, opts?: { force?: boolean }): Promise<BridgeClaimResult>
265
+ close(): void
266
+ }
267
+
268
+ export interface BridgeClaimResult {
269
+ simId: string
270
+ lockedBy: string
271
+ lockExpiresAt: number
272
+ bootedCount: number
273
+ }
274
+
275
+ export interface BridgeLockInfo {
276
+ by: string
277
+ expiresInMs: number
278
+ }
279
+
280
+ export class BridgeSimLockedError extends Error {
281
+ lock: BridgeLockInfo
282
+ constructor(message: string, lock: BridgeLockInfo) {
283
+ super(message)
284
+ this.name = 'BridgeSimLockedError'
285
+ this.lock = lock
286
+ }
287
+ }
288
+
289
+ interface WsResponse {
290
+ id: number
291
+ result?: any
292
+ error?: string
293
+ }
294
+
295
+ interface WsBridgeOptions {
296
+ commandTimeoutMs?: number
297
+ simId?: string
298
+ cliLabel?: string
299
+ simIdSource?: ParsedBridgeCliArgs['simIdSource']
300
+ }
301
+
302
+ // label shown in the target-sim notice. explains where the simId came
303
+ // from so an agent can tell "I asked for a7" vs "I got default primary".
304
+ function describeSimIdSource(
305
+ source: ParsedBridgeCliArgs['simIdSource'] | undefined,
306
+ ): string {
307
+ if (source === 'flag') return 'via --sim'
308
+ if (source === 'saved') return 'saved via `rnxsim use`'
309
+ return 'primary fallback — no sim pinned'
310
+ }
311
+
312
+ export interface BridgeSimInfo {
313
+ id: string
314
+ origin?: string
315
+ url?: string
316
+ title?: string
317
+ userAgent?: string
318
+ connectedAt: number
319
+ lastSeenAt: number
320
+ lastActiveAt?: number
321
+ isPrimary: boolean
322
+ readyState: 'open' | 'closing' | 'closed'
323
+ attachedCliCount?: number
324
+ lockedBy?: string
325
+ lockedByKind?: 'cli' | 'user-active'
326
+ lockExpiresAt?: number
327
+ userFocused?: boolean
328
+ userVisible?: boolean
329
+ visibilityState?: string
330
+ documentFocused?: boolean
331
+ /** registration kind — 'sootsim' (default) or 'contrast'. used by the
332
+ * `contrast` CLI to filter, and by tooling that wants to distinguish a
333
+ * Contrast IDE tab from a regular sootsim runtime. */
334
+ kind?: string
335
+ /** opaque metadata supplied at register time (projectId, route, the
336
+ * attached iOS sim id, etc). free-form by design. */
337
+ meta?: Record<string, unknown>
338
+ }
339
+
340
+ interface ParseBridgeCliArgsOptions {
341
+ port?: number
342
+ commandTimeoutMs?: number
343
+ stripBooleanFlags?: string[]
344
+ stripValueFlags?: string[]
345
+ }
346
+
347
+ export interface ParsedBridgeCliArgs {
348
+ positional: string[]
349
+ wsPort: number
350
+ explicitPort: boolean
351
+ simId?: string
352
+ simIdSource: 'flag' | 'saved' | 'none'
353
+ commandTimeoutMs: number
354
+ }
355
+
356
+ export function parseBridgeCliArgs(
357
+ args: string[],
358
+ opts: ParseBridgeCliArgsOptions = {},
359
+ ): ParsedBridgeCliArgs {
360
+ const flagIndices = new Set<number>()
361
+ let wsPort = opts.port ?? resolveDefaultBridgePort()
362
+ let explicitPort = opts.port !== undefined
363
+ let commandTimeoutMs = opts.commandTimeoutMs ?? 15000
364
+ let simId: string | undefined
365
+ let simIdSource: ParsedBridgeCliArgs['simIdSource'] = 'none'
366
+
367
+ const stripBooleanFlags = new Set(opts.stripBooleanFlags ?? [])
368
+ const stripValueFlags = new Set(opts.stripValueFlags ?? [])
369
+
370
+ for (let i = 0; i < args.length; i++) {
371
+ const arg = args[i]
372
+ if (arg === '--port' || arg === '-p') {
373
+ flagIndices.add(i)
374
+ explicitPort = true
375
+ if (i + 1 < args.length) {
376
+ flagIndices.add(i + 1)
377
+ wsPort = Number(args[i + 1])
378
+ }
379
+ i++
380
+ continue
381
+ }
382
+ if (arg.startsWith('--port=')) {
383
+ flagIndices.add(i)
384
+ explicitPort = true
385
+ wsPort = Number(arg.slice('--port='.length))
386
+ continue
387
+ }
388
+ if (arg === '--timeout') {
389
+ flagIndices.add(i)
390
+ if (i + 1 < args.length) {
391
+ flagIndices.add(i + 1)
392
+ commandTimeoutMs = Number(args[i + 1])
393
+ }
394
+ i++
395
+ continue
396
+ }
397
+ // `--sim` is the canonical target flag; `--session` / `--tab` are
398
+ // documented aliases (CLAUDE.md, the SootSim agent skills, and engine docs all
399
+ // say `--session <id>`). without these aliases an agent following the docs
400
+ // would have `--session ef` silently ignored and get the *currently
401
+ // pinned* sim's tree instead — wrong-app data with no error. accept the
402
+ // `--flag value` and `--flag=value` forms for all three spellings.
403
+ if (arg === '--sim' || arg === '--session' || arg === '--tab') {
404
+ flagIndices.add(i)
405
+ if (i + 1 < args.length) {
406
+ flagIndices.add(i + 1)
407
+ simId = args[i + 1]?.trim() || undefined
408
+ simIdSource = 'flag'
409
+ }
410
+ i++
411
+ continue
412
+ }
413
+ const simEq = arg.startsWith('--sim=')
414
+ ? arg.slice('--sim='.length)
415
+ : arg.startsWith('--session=')
416
+ ? arg.slice('--session='.length)
417
+ : arg.startsWith('--tab=')
418
+ ? arg.slice('--tab='.length)
419
+ : undefined
420
+ if (simEq !== undefined) {
421
+ flagIndices.add(i)
422
+ simId = simEq.trim() || undefined
423
+ simIdSource = 'flag'
424
+ continue
425
+ }
426
+ if (stripBooleanFlags.has(arg)) {
427
+ flagIndices.add(i)
428
+ continue
429
+ }
430
+ if (stripValueFlags.has(arg)) {
431
+ flagIndices.add(i)
432
+ if (i + 1 < args.length) {
433
+ flagIndices.add(i + 1)
434
+ }
435
+ i++
436
+ }
437
+ }
438
+
439
+ if (!simId) {
440
+ const savedSimId = readCurrentSimId()
441
+ if (savedSimId) {
442
+ simId = savedSimId.trim() || undefined
443
+ simIdSource = 'saved'
444
+ }
445
+ }
446
+
447
+ return {
448
+ positional: args.filter((_, i) => !flagIndices.has(i)),
449
+ wsPort,
450
+ explicitPort,
451
+ simId,
452
+ simIdSource,
453
+ commandTimeoutMs,
454
+ }
455
+ }
456
+
457
+ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBridge {
458
+ let nextId = 1
459
+ const commandTimeoutMs = opts.commandTimeoutMs ?? 15000
460
+ const pending = new Map<
461
+ number,
462
+ { resolve: (v: any) => void; reject: (e: Error) => void }
463
+ >()
464
+
465
+ const ws = new WebSocket(`ws://localhost:${wsPort}`)
466
+ // always key the lease on the stable agent/terminal identity, never on the
467
+ // target simId. keying off `sim:<id>` conflated "which sim" with "who is
468
+ // driving it": a single agent's identity then flipped between its stable key
469
+ // (untargeted commands) and `sim:<id>` (targeted commands), so the agent
470
+ // could take a lease under one key and get refused under the other — locked
471
+ // out of a sim it owns. it also made two different agents driving the same
472
+ // sim collide into one identity and trample each other's lease. getCliIdentity()
473
+ // is already stable per agent run (CLAUDE_CODE_SESSION_ID / per-terminal) and
474
+ // distinct between agents — exactly what the lease wants.
475
+ const identity = getCliIdentity()
476
+
477
+ const ready = new Promise<void>((resolve, reject) => {
478
+ ws.on('open', () => {
479
+ // identify ourselves so the server keys leases on the agent run,
480
+ // not the ephemeral ws connection (CLI invocations are short-lived).
481
+ // when identity is unstable the server falls back to a per-socket key,
482
+ // which is still safer than letting multiple agents share a sim.
483
+ try {
484
+ ws.send(
485
+ JSON.stringify({
486
+ type: 'bridge:hello',
487
+ id: 0,
488
+ cliIdentityKey: identity.key,
489
+ cliIdentitySource: identity.source,
490
+ cliLabel: opts.cliLabel,
491
+ }),
492
+ )
493
+ } catch {}
494
+ resolve()
495
+ })
496
+ ws.on('error', (err) => {
497
+ // err.message is often empty (ECONNREFUSED surfaces with no message on
498
+ // some node builds) — don't emit a dangling "...7668: " with no cause.
499
+ const cause = err.message ? `: ${err.message}` : ''
500
+ reject(new Error(`could not connect to ws://localhost:${wsPort}${cause}`))
501
+ })
502
+ })
503
+ // a bridge closed before its handshake finished rejects `ready` with nothing
504
+ // awaiting it (every send awaits ready and still sees the rejection); mark it
505
+ // handled so that path never surfaces as an unhandled rejection.
506
+ ready.catch(() => {})
507
+
508
+ let warnedAboutContention = false
509
+ let noticedTarget = false
510
+
511
+ // print a one-line notice on first sim-scoped command so agents/users see
512
+ // which sim the command actually hit. suppressed for bridge:*
513
+ // control commands (list/open/focus/close/claim) that don't target a
514
+ // single sim, and for the hidden SOOTSIM_QUIET_TARGET_NOTICE escape hatch
515
+ // used by scripts that prefer clean stdout.
516
+ function maybeNoticeTarget(cmdType: string, simId: string | undefined) {
517
+ if (noticedTarget) return
518
+ if (process.env.SOOTSIM_QUIET_TARGET_NOTICE === '1') return
519
+ if (cmdType.startsWith('bridge:') || cmdType === 'focus' || cmdType === 'close')
520
+ return
521
+ noticedTarget = true
522
+ const label = simId ?? 'primary'
523
+ const source = describeSimIdSource(simId ? opts.simIdSource : 'none')
524
+ process.stderr.write(` → ${label} (${source})\n`)
525
+ }
526
+
527
+ ws.on('message', (data) => {
528
+ let msg: any
529
+ try {
530
+ msg = JSON.parse(data.toString())
531
+ } catch {
532
+ return
533
+ }
534
+ if (msg.id === 0) return // bridge:hello ack
535
+ const p = pending.get(msg.id)
536
+ if (!p) return
537
+ pending.delete(msg.id)
538
+
539
+ // warn once if another CLI identity is also targeting this sim. this is
540
+ // NOT a bridge-throughput / congestion warning — the bridge is a thin ws
541
+ // relay and handles many messages/sec. it's a *semantic* warning: two
542
+ // agents driving the same screen will collide on taps/scroll/keyboard
543
+ // state. writes already serialize via the cli lease; reads pass through.
544
+ if (msg._otherCliCount > 0 && !warnedAboutContention) {
545
+ warnedAboutContention = true
546
+ process.stderr.write(
547
+ `\n ⚠ ${msg._otherCliCount} other CLI identity/identities are driving this sim\n` +
548
+ ' taps, scrolls, and keyboard input from multiple agents will collide on\n' +
549
+ ' the same screen state (this is not a bridge throughput limit).\n' +
550
+ ' use `rnxsim open --new` for an isolated sim per agent.\n\n',
551
+ )
552
+ }
553
+
554
+ if (msg.error) {
555
+ if (msg._locked) {
556
+ p.reject(new BridgeSimLockedError(msg.error, msg._locked as BridgeLockInfo))
557
+ } else {
558
+ p.reject(new Error(msg.error))
559
+ }
560
+ } else {
561
+ p.resolve(msg.result)
562
+ }
563
+ })
564
+
565
+ // when the bridge host or target sim disconnects, reject all pending commands
566
+ // so the CLI gets a clear error instead of silently timing out
567
+ ws.on('close', (code, reason) => {
568
+ const msg = reason?.toString() || 'connection closed'
569
+ for (const [id, p] of pending) {
570
+ pending.delete(id)
571
+ p.reject(new Error(`sim disconnected: ${msg} (code ${code})`))
572
+ }
573
+ })
574
+
575
+ return {
576
+ async send(cmd, callOpts) {
577
+ // NOTE: `opts` (the createBridge closure arg) carries simId; do not
578
+ // shadow it with the per-call arg.
579
+ const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
580
+ const sendOnce = async (defaultSimId?: string) => {
581
+ await ready
582
+ const id = nextId++
583
+ return new Promise((resolve, reject) => {
584
+ const timeout = setTimeout(() => {
585
+ pending.delete(id)
586
+ reject(
587
+ new Error(
588
+ `command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
589
+ ),
590
+ )
591
+ }, effectiveTimeoutMs)
592
+
593
+ pending.set(id, {
594
+ resolve: (v) => {
595
+ clearTimeout(timeout)
596
+ resolve(v)
597
+ },
598
+ reject: (e) => {
599
+ clearTimeout(timeout)
600
+ reject(e)
601
+ },
602
+ })
603
+
604
+ const payload = { ...cmd, id } as Record<string, any>
605
+ if (payload.simId === undefined && defaultSimId) {
606
+ payload.simId = defaultSimId
607
+ }
608
+ maybeNoticeTarget(cmd.type ?? '', payload.simId)
609
+ ws.send(JSON.stringify(payload))
610
+ })
611
+ }
612
+
613
+ // re-resolve the saved pin per send for bridges that were not
614
+ // explicitly `--sim`-targeted. a poll loop (`wait ready`) started
615
+ // before a concurrent `sootsim open` finished connecting would
616
+ // otherwise stay on the primary fallback for its whole budget, probing
617
+ // an unrelated (often empty) sim while the intended one connects and
618
+ // pins itself mid-wait. falling back to the startup pin keeps the
619
+ // fail-closed saved-sim-gone path below when the pin file is cleared.
620
+ const sentSimId =
621
+ opts.simIdSource === 'flag' ? opts.simId : (readCurrentSimId() ?? opts.simId)
622
+ try {
623
+ return await sendOnce(sentSimId)
624
+ } catch (error) {
625
+ const message = error instanceof Error ? error.message : String(error)
626
+ const missingSimMessage = `no sim connected with id ${sentSimId}`
627
+ if (
628
+ opts.simIdSource !== 'flag' &&
629
+ sentSimId &&
630
+ cmd.simId === undefined &&
631
+ (message === missingSimMessage || message.startsWith(`${missingSimMessage};`))
632
+ ) {
633
+ clearCurrentSimId()
634
+ // the saved sim is gone. fail closed instead of driving whichever
635
+ // sim is primary; that is how agents end up recording the wrong app.
636
+ throw new Error(
637
+ `saved sim ${sentSimId} is gone${message.slice(missingSimMessage.length)}; ` +
638
+ 'run `rnxsim list` and ' +
639
+ `\`rnxsim use <sim>\`, or open a fresh sim`,
640
+ )
641
+ }
642
+ throw error
643
+ }
644
+ },
645
+ async listSims() {
646
+ const sims = await this.send({ type: 'bridge:list-sims' })
647
+ return Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []
648
+ },
649
+ async focusSim(simId?: string) {
650
+ return this.send({ type: 'focus', simId })
651
+ },
652
+ async closeSim(simId?: string) {
653
+ return this.send({ type: 'close', simId })
654
+ },
655
+ async claim(simId?: string, claimOpts: { force?: boolean } = {}) {
656
+ const result = await this.send({
657
+ type: 'bridge:claim',
658
+ simId,
659
+ force: claimOpts.force === true,
660
+ })
661
+ return result as BridgeClaimResult
662
+ },
663
+ close() {
664
+ // send an explicit goodbye so the server deregisters this socket
665
+ // synchronously instead of waiting on the tcp close event, which
666
+ // can lag the next cli invocation and produce phantom-peer warnings.
667
+ try {
668
+ if (ws.readyState === WebSocket.OPEN) {
669
+ ws.send(JSON.stringify({ type: 'bridge:bye', id: 0 }))
670
+ }
671
+ } catch {}
672
+ ws.close()
673
+ const terminateTimer: ReturnType<typeof setTimeout> = setTimeout(() => {
674
+ if (ws.readyState !== WebSocket.CLOSED) {
675
+ ws.terminate()
676
+ }
677
+ }, 250)
678
+ },
679
+ }
680
+ }
681
+
682
+ export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge {
683
+ return createBridge(parsed.wsPort, {
684
+ commandTimeoutMs: parsed.commandTimeoutMs,
685
+ simId: parsed.simId,
686
+ simIdSource: parsed.simIdSource,
687
+ })
688
+ }
689
+
690
+ interface SimHealthProbe {
691
+ hidden?: boolean
692
+ visibilityState?: string
693
+ hasFocus?: boolean | null
694
+ simId?: string | null
695
+ url?: string
696
+ }
697
+
698
+ function describeVisibleSimCandidate(sims: BridgeSimInfo[], currentId?: string | null) {
699
+ const open = sims.filter((sim) => sim.readyState === 'open' && sim.id !== currentId)
700
+ const candidate =
701
+ open.find((sim) => sim.userFocused) ??
702
+ open.find((sim) => sim.userVisible === true && sim.isPrimary) ??
703
+ open.find((sim) => sim.userVisible === true) ??
704
+ open.find((sim) => sim.isPrimary)
705
+ if (!candidate) return null
706
+ const tags = [
707
+ candidate.isPrimary ? 'primary' : null,
708
+ candidate.userVisible === false
709
+ ? 'hidden'
710
+ : candidate.userVisible === true
711
+ ? 'visible'
712
+ : null,
713
+ candidate.userFocused ? 'focused' : null,
714
+ ].filter(Boolean)
715
+ return `${candidate.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`
716
+ }
717
+
718
+ /**
719
+ * Check if the target sim is healthy for interaction. Warns on stderr if:
720
+ * - document.hidden is true (animations won't run, coordinates may be wrong)
721
+ * Returns the health state so callers can decide whether to proceed.
722
+ */
723
+ export async function checkSimHealth(
724
+ bridge: WsBridge,
725
+ ): Promise<{ hidden: boolean; warned: boolean; simId?: string | null }> {
726
+ try {
727
+ const probe = (await bridge.send({
728
+ type: 'evaluate',
729
+ code: `(() => ({
730
+ hidden: document.hidden,
731
+ visibilityState: document.visibilityState,
732
+ hasFocus: typeof document.hasFocus === 'function' ? document.hasFocus() : null,
733
+ simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null,
734
+ url: location.href
735
+ }))()`,
736
+ })) as SimHealthProbe | boolean | null
737
+ const health =
738
+ typeof probe === 'object' && probe !== null ? probe : { hidden: probe === true }
739
+ const hidden = health.hidden === true
740
+ if (hidden === true) {
741
+ let candidate: string | null = null
742
+ try {
743
+ candidate = describeVisibleSimCandidate(await bridge.listSims(), health.simId)
744
+ } catch {}
745
+ process.stderr.write(
746
+ `\n ⚠ target sim${health.simId ? ` ${health.simId}` : ''} is hidden (document.hidden = true)\n` +
747
+ (health.visibilityState ? ` visibility: ${health.visibilityState}\n` : '') +
748
+ ' animations and rAF callbacks are throttled — coordinates may be wrong\n' +
749
+ ' and launch/transition animations will not complete.\n' +
750
+ (candidate
751
+ ? ` another open sim looks usable: ${candidate}; target it with --sim.\n`
752
+ : ' run `rnxsim list` to find the visible sim and target it with --sim.\n') +
753
+ ' page-level window.focus() cannot unhide a browser tab.\n\n',
754
+ )
755
+ return { hidden: true, warned: true, simId: health.simId }
756
+ }
757
+ return { hidden: false, warned: false, simId: health.simId }
758
+ } catch {
759
+ return { hidden: false, warned: false }
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Convenience: run arbitrary JavaScript in the sootsim page and return the
765
+ * result. Hides the `type: 'evaluate'` message shape. Read-only by default —
766
+ * pass `{ acquireLock: true }` when the eval mutates user-facing state so
767
+ * the server takes/refreshes the cli lease and blocks concurrent agents.
768
+ */
769
+ export async function evalInBridge<T = unknown>(
770
+ bridge: WsBridge,
771
+ code: string,
772
+ opts: { acquireLock?: boolean } = {},
773
+ ): Promise<T> {
774
+ const payload: { type: string; [k: string]: any } = { type: 'evaluate', code }
775
+ if (opts.acquireLock) payload.acquireLock = true
776
+ return bridge.send(payload) as Promise<T>
777
+ }
778
+
779
+ /**
780
+ * Invoke a function on `window.SootSim.bridges.*` over the bridge. Read-only
781
+ * by default for the same reason as evalInBridge.
782
+ */
783
+ export async function callInBridge<T = unknown>(
784
+ bridge: WsBridge,
785
+ path: string,
786
+ ...args: unknown[]
787
+ ): Promise<T> {
788
+ return bridge.send({ type: 'call', path, args }) as Promise<T>
789
+ }
790
+
791
+ /** Write variant — takes/refreshes the cli lease on the target sim. */
792
+ export async function callInBridgeWrite<T = unknown>(
793
+ bridge: WsBridge,
794
+ path: string,
795
+ ...args: unknown[]
796
+ ): Promise<T> {
797
+ return bridge.send({ type: 'call', path, args, acquireLock: true }) as Promise<T>
798
+ }