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,1020 @@
1
+ // rnx CLI ↔ sim WS bridge
2
+ //
3
+ // the rnx 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 rnx 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
+ rnxHomeDir,
23
+ type DevBridgeLockfile,
24
+ } from '../src/home-paths'
25
+ import { createCloudBridgeForParsed } from './cloud-client'
26
+ import {
27
+ clearCurrentSimId,
28
+ getCliIdentity,
29
+ readCurrentSim,
30
+ readCurrentSimId,
31
+ saveCurrentSimId,
32
+ } from './current-sim'
33
+
34
+ // --- bridge world resolution ---------------------------------------------
35
+ //
36
+ // two bridge kinds can coexist on one machine: Vite dev-shell bridges
37
+ // (~/.rnx/dev-bridges/<port>.json, shells served by Vite) and the installed
38
+ // daemon runtime (~/.rnx/daemon.json, shell served by the daemon's own
39
+ // http server). every default — the ws port a command connects to AND the
40
+ // shell base URL `open` builds its page from — must come from the SAME
41
+ // world, or `open` polls one bridge while the sim registers on the other
42
+ // (the 125s-timeout split-brain bug). resolveBridgeWorld is the single
43
+ // source of that decision: an invocation's explicit offset wins, then its
44
+ // checkout's dev bridge, then the canonical dev bridge, a fresh daemon, and
45
+ // finally hardcoded defaults.
46
+
47
+ export interface BridgeWorld {
48
+ source: 'dev-bridge' | 'daemon' | 'default'
49
+ bridgePort: number
50
+ shellBaseUrl: string
51
+ }
52
+
53
+ // where this invocation is running. the same rnx commands work in four
54
+ // places, and the placement is what a command consults instead of growing a
55
+ // second spelling per place. host and local-box share one transport today —
56
+ // a local box mounts this machine's checkout and drives this machine's
57
+ // bridge — so they differ in what is allowed, not in where packets go. a
58
+ // cloud box is the one that moves the endpoint, and it does not exist yet.
59
+ export type RnxPlacement = 'host' | 'local-box' | 'cloud-box'
60
+
61
+ // set by `rnx box` on the shell's child processes. the box is the only thing
62
+ // that may set it; nothing infers a placement from cwd or heuristics.
63
+ export const RNX_PLACEMENT_ENV = 'RNX_PLACEMENT'
64
+
65
+ export function resolvePlacement(): RnxPlacement {
66
+ return process.env[RNX_PLACEMENT_ENV] === 'local-box' ? 'local-box' : 'host'
67
+ }
68
+
69
+ /** the ws endpoint a command connects to. the single place a bridge URL is
70
+ * formed, so a cloud box changes this function rather than every call site.
71
+ * host and local-box both drive this machine's bridge.
72
+ *
73
+ * changing what this returns changes real egress, and the outbound inventory
74
+ * declares the CALL EXPRESSION rather than the URL, so it cannot see through
75
+ * this function. a cloud endpoint added here must get its own declaration in
76
+ * `outbound-endpoints.ts` instead of inheriting the local_development one. */
77
+ export function bridgeWsUrl(wsPort: number): string {
78
+ return `ws://127.0.0.1:${wsPort}`
79
+ }
80
+
81
+ // resolved once per CLI process so a multi-step command keeps one world for
82
+ // its entire invocation. separate CLI calls make the same deterministic choice
83
+ // from the owner-scoped dev records instead of following whichever heartbeat
84
+ // happened to run last.
85
+ let resolvedWorld: { homeDir: string; world: BridgeWorld } | null = null
86
+
87
+ function liveDevBridgeLockfiles(): DevBridgeLockfile[] {
88
+ return readDevBridgeLockfiles().filter((lockfile) => isDevBridgeLockfileFresh(lockfile))
89
+ }
90
+
91
+ function checkoutRoot(cwd: string): string | null {
92
+ let current = path.resolve(cwd)
93
+ while (true) {
94
+ if (fs.existsSync(path.join(current, '.git'))) return current
95
+ const parent = path.dirname(current)
96
+ if (parent === current) return null
97
+ current = parent
98
+ }
99
+ }
100
+
101
+ function pathContains(parent: string, child: string): boolean {
102
+ const relative = path.relative(parent, child)
103
+ return (
104
+ relative === '' ||
105
+ (relative !== '..' &&
106
+ !relative.startsWith(`..${path.sep}`) &&
107
+ !path.isAbsolute(relative))
108
+ )
109
+ }
110
+
111
+ function resolveDefaultDevBridgeLockfile(): DevBridgeLockfile | null {
112
+ const lockfiles = liveDevBridgeLockfiles()
113
+ if (lockfiles.length === 0) return null
114
+
115
+ if (
116
+ process.env.SOOTSIM_BRIDGE_PORT !== undefined ||
117
+ process.env.PORT_OFFSET !== undefined
118
+ ) {
119
+ const requestedPort = resolveSootsimBridgePort({
120
+ explicitPort: process.env.SOOTSIM_BRIDGE_PORT,
121
+ portOffset: process.env.PORT_OFFSET,
122
+ })
123
+ const requested = lockfiles.find((lockfile) => lockfile.bridgePort === requestedPort)
124
+ if (requested) return requested
125
+ }
126
+
127
+ let cwd: string | null = null
128
+ try {
129
+ cwd = process.cwd()
130
+ } catch {}
131
+ if (cwd) {
132
+ const currentCheckout = checkoutRoot(cwd)
133
+ const sameCheckout = lockfiles.filter((lockfile) => {
134
+ const bridgeCheckout = checkoutRoot(lockfile.cwd)
135
+ if (currentCheckout && bridgeCheckout) return currentCheckout === bridgeCheckout
136
+ return pathContains(cwd, lockfile.cwd) || pathContains(lockfile.cwd, cwd)
137
+ })
138
+ if (sameCheckout.length > 0) {
139
+ return (
140
+ sameCheckout.find(
141
+ (lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT,
142
+ ) ?? sameCheckout[0]
143
+ )
144
+ }
145
+ }
146
+
147
+ return (
148
+ lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ??
149
+ lockfiles[0]
150
+ )
151
+ }
152
+
153
+ export function resolveBridgeWorld(): BridgeWorld {
154
+ const homeDir = rnxHomeDir()
155
+ if (resolvedWorld?.homeDir === homeDir) return resolvedWorld.world
156
+ const world = resolveBridgeWorldFresh()
157
+ resolvedWorld = { homeDir, world }
158
+ return world
159
+ }
160
+
161
+ function resolveBridgeWorldFresh(): BridgeWorld {
162
+ const dev = resolveDefaultDevBridgeLockfile()
163
+ if (dev) {
164
+ return {
165
+ source: 'dev-bridge',
166
+ bridgePort: dev.bridgePort,
167
+ // older lockfile writers don't record the vite http port; the default
168
+ // shell URL is the dev shell anyway, so the fallback stays coherent.
169
+ shellBaseUrl: dev.shellPort
170
+ ? `http://localhost:${dev.shellPort}/`
171
+ : DEFAULT_SOOTSIM_SHELL_URL,
172
+ }
173
+ }
174
+ const lock = readDaemonLockfile()
175
+ if (lock && isDaemonLockfileFresh(lock)) {
176
+ return {
177
+ source: 'daemon',
178
+ bridgePort: lock.bridgePort,
179
+ shellBaseUrl:
180
+ lock.runtimePort > 0
181
+ ? `http://localhost:${lock.runtimePort}/`
182
+ : DEFAULT_SOOTSIM_SHELL_URL,
183
+ }
184
+ }
185
+ return {
186
+ source: 'default',
187
+ bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT,
188
+ shellBaseUrl: DEFAULT_SOOTSIM_SHELL_URL,
189
+ }
190
+ }
191
+
192
+ /** the world a specific bridge port belongs to, or null when no fresh
193
+ * lockfile claims it. lets `open --port 7669` derive the daemon's shell
194
+ * base URL instead of mixing worlds. */
195
+ export function worldForBridgePort(
196
+ port: number,
197
+ ): { source: 'dev-bridge' | 'daemon'; shellBaseUrl: string } | null {
198
+ const dev = liveDevBridgeLockfiles().find((lockfile) => lockfile.bridgePort === port)
199
+ if (dev) {
200
+ return {
201
+ source: 'dev-bridge',
202
+ shellBaseUrl: dev.shellPort
203
+ ? `http://localhost:${dev.shellPort}/`
204
+ : DEFAULT_SOOTSIM_SHELL_URL,
205
+ }
206
+ }
207
+ const lock = readDaemonLockfile()
208
+ if (lock && isDaemonLockfileFresh(lock) && lock.bridgePort === port) {
209
+ return {
210
+ source: 'daemon',
211
+ shellBaseUrl:
212
+ lock.runtimePort > 0
213
+ ? `http://localhost:${lock.runtimePort}/`
214
+ : DEFAULT_SOOTSIM_SHELL_URL,
215
+ }
216
+ }
217
+ return null
218
+ }
219
+
220
+ /** the bridge port of the world that serves a given shell base URL, or null
221
+ * when no fresh lockfile claims it. lets `open --base-url` (without --port)
222
+ * poll the bridge that actually belongs to that shell. */
223
+ export function bridgePortForShellBaseUrl(baseUrl: string): number | null {
224
+ let port: number
225
+ try {
226
+ const url = new URL(baseUrl)
227
+ port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
228
+ } catch {
229
+ return null
230
+ }
231
+ if (!Number.isFinite(port) || port <= 0) return null
232
+ const dev = liveDevBridgeLockfiles().find(
233
+ (lockfile) =>
234
+ lockfile.shellPort === port ||
235
+ lockfile.runtimePort === port ||
236
+ lockfile.bridgePort === port,
237
+ )
238
+ if (dev) {
239
+ return dev.bridgePort
240
+ }
241
+ const lock = readDaemonLockfile()
242
+ if (
243
+ lock &&
244
+ isDaemonLockfileFresh(lock) &&
245
+ (lock.runtimePort === port || lock.bridgePort === port)
246
+ ) {
247
+ return lock.bridgePort
248
+ }
249
+ return null
250
+ }
251
+
252
+ /** the bridge a `--base-url` pin implies. one rule for every command that takes
253
+ * the flag, because they used to disagree: flow refused an unserved pin while
254
+ * open kept the discovered default and then started a bridge of its own, so
255
+ * the page rendered in the pinned shell and the polling waited on another
256
+ * world's bridge. that reads as "saved sim <id> is gone" and gets retried
257
+ * rather than fixed. an explicit --port states the world outright and wins;
258
+ * otherwise the pin has to name a world something serves. */
259
+ export function resolveBridgePortForPin(pin: {
260
+ baseUrl: string
261
+ wsPort: number
262
+ explicitPort: boolean
263
+ }): { port: number; error?: undefined } | { port?: undefined; error: string } {
264
+ const served = bridgePortForShellBaseUrl(pin.baseUrl)
265
+ if (served === null) {
266
+ if (pin.explicitPort) return { port: pin.wsPort }
267
+ return {
268
+ error:
269
+ `no live rnx world serves ${pin.baseUrl} — start that shell first, ` +
270
+ 'or name its bridge with --port <port>',
271
+ }
272
+ }
273
+ if (pin.explicitPort && pin.wsPort !== served) {
274
+ return {
275
+ error: `--base-url ${pin.baseUrl} belongs to bridge port ${served}, but --port ${pin.wsPort} names another world`,
276
+ }
277
+ }
278
+ return { port: served }
279
+ }
280
+
281
+ /** default ws bridge port for CLI commands: the fresh dev bridge when a vite
282
+ * shell is live, else the fresh daemon, else the hardcoded default. one
283
+ * deterministic answer regardless of how the CLI was invoked (global binary
284
+ * vs repo checkout) — invocation-path heuristics made `rnx list` flap
285
+ * between worlds. */
286
+ export function resolveDefaultBridgePort(): number {
287
+ return resolveBridgeWorld().bridgePort
288
+ }
289
+
290
+ /** one-line stderr notice naming the bridge world a command resolved, so the
291
+ * dev-bridge vs daemon pick is never silent (the split-brain bug hid behind
292
+ * exactly this silence). pass the shell base URL actually used when the
293
+ * caller resolved one (open); otherwise the port's own world URL prints. */
294
+ export function printBridgeWorldNotice(
295
+ bridgePort: number,
296
+ shellBaseUrl?: string | null,
297
+ ): void {
298
+ const world = worldForBridgePort(bridgePort)
299
+ const kind =
300
+ world?.source === 'dev-bridge'
301
+ ? 'dev bridge'
302
+ : world?.source === 'daemon'
303
+ ? 'daemon bridge'
304
+ : 'bridge'
305
+ const origin =
306
+ world?.source === 'dev-bridge'
307
+ ? `dev-bridges/${bridgePort}.json`
308
+ : world?.source === 'daemon'
309
+ ? 'daemon.json'
310
+ : 'no lockfile'
311
+ const shell = shellBaseUrl ?? world?.shellBaseUrl
312
+ process.stderr.write(
313
+ ` → ${kind} :${bridgePort} (${shell ? `shell ${shell}, ` : ''}${origin})\n`,
314
+ )
315
+ }
316
+
317
+ export interface WsBridge {
318
+ readonly plane?: 'local' | 'cloud'
319
+ // `opts.timeoutMs` overrides the bridge-wide commandTimeoutMs for a single
320
+ // call — used for fast readiness probes so a dead/unresponsive sim fails in
321
+ // seconds instead of stalling the full command timeout.
322
+ send(
323
+ cmd: { type: string; [k: string]: any },
324
+ opts?: { timeoutMs?: number },
325
+ ): Promise<any>
326
+ listSims(): Promise<BridgeSimInfo[]>
327
+ // a guest reload retires the socket and the same page reconnects under a new
328
+ // or restored id. a caller that pins an id resolves that target here; null
329
+ // means nothing proves what replaced it.
330
+ resolveReloadedSim(retiredSimId: string): Promise<string | null>
331
+ focusSim(simId?: string): Promise<any>
332
+ closeSim(simId?: string): Promise<any>
333
+ claim(simId?: string, opts?: { force?: boolean }): Promise<BridgeClaimResult>
334
+ close(): void
335
+ }
336
+
337
+ export interface BridgeClaimResult {
338
+ simId: string
339
+ lockedBy: string
340
+ lockExpiresAt: number
341
+ bootedCount: number
342
+ }
343
+
344
+ export interface BridgeLockInfo {
345
+ by: string
346
+ expiresInMs: number
347
+ }
348
+
349
+ export class BridgeSimLockedError extends Error {
350
+ lock: BridgeLockInfo
351
+ constructor(message: string, lock: BridgeLockInfo) {
352
+ super(message)
353
+ this.name = 'BridgeSimLockedError'
354
+ this.lock = lock
355
+ }
356
+ }
357
+
358
+ interface WsResponse {
359
+ id: number
360
+ result?: any
361
+ error?: string
362
+ }
363
+
364
+ interface WsBridgeOptions {
365
+ commandTimeoutMs?: number
366
+ simId?: string
367
+ cliLabel?: string
368
+ simIdSource?: ParsedBridgeCliArgs['simIdSource']
369
+ }
370
+
371
+ // label shown in the target-sim notice. explains where the simId came
372
+ // from so an agent can tell "I asked for a7" vs "I got default primary".
373
+ function describeSimIdSource(
374
+ source: ParsedBridgeCliArgs['simIdSource'] | undefined,
375
+ ): string {
376
+ if (source === 'flag') return 'via --sim'
377
+ if (source === 'saved') return 'saved via `rnx use`'
378
+ return 'primary fallback — no sim pinned'
379
+ }
380
+
381
+ export interface BridgeSimInfo {
382
+ id: string
383
+ origin?: string
384
+ url?: string
385
+ title?: string
386
+ userAgent?: string
387
+ connectedAt: number
388
+ lastSeenAt: number
389
+ lastActiveAt?: number
390
+ isPrimary: boolean
391
+ readyState: 'open' | 'closing' | 'closed'
392
+ attachedCliCount?: number
393
+ lockedBy?: string
394
+ lockedByKind?: 'cli' | 'user-active'
395
+ lockExpiresAt?: number
396
+ /** true when this sim's lease belongs to us. computed server-side. */
397
+ lockedByMe?: boolean
398
+ userFocused?: boolean
399
+ userVisible?: boolean
400
+ visibilityState?: string
401
+ documentFocused?: boolean
402
+ /** registration kind — 'sootsim' (default) or 'contrast'. used by the
403
+ * `contrast` CLI to filter, and by tooling that wants to distinguish a
404
+ * Contrast IDE tab from a regular rnx runtime. */
405
+ kind?: string
406
+ /** opaque metadata supplied at register time (projectId, route, the
407
+ * attached iOS sim id, etc). free-form by design. */
408
+ meta?: Record<string, unknown>
409
+ }
410
+
411
+ // a lock owner is either a friendly cli label ("active user") or a raw cli
412
+ // identity key ("CLAUDE_CODE_SESSION_ID:<uuid>", "gppid-1234"). keep the list
413
+ // readable by collapsing a "<source>:<value>" identity to a short form that
414
+ // still says which agent holds it.
415
+ export function formatLockOwner(owner: string): string {
416
+ const colon = owner.indexOf(':')
417
+ if (colon <= 0) return owner.length > 24 ? `${owner.slice(0, 21)}…` : owner
418
+ const source = owner.slice(0, colon)
419
+ const value = owner.slice(colon + 1)
420
+ const short = value.length > 12 ? `${value.slice(0, 8)}…` : value
421
+ if (
422
+ source === 'TM_SESSION' ||
423
+ source === 'CLAUDE_CODE_SESSION_ID' ||
424
+ source === 'CODEX_THREAD_ID'
425
+ ) {
426
+ return `agent ${short}`
427
+ }
428
+ return `${source} ${short}`
429
+ }
430
+
431
+ interface ParseBridgeCliArgsOptions {
432
+ port?: number
433
+ commandTimeoutMs?: number
434
+ stripBooleanFlags?: string[]
435
+ stripValueFlags?: string[]
436
+ }
437
+
438
+ export interface ParsedBridgeCliArgs {
439
+ positional: string[]
440
+ wsPort: number
441
+ explicitPort: boolean
442
+ simId?: string
443
+ simIdSource: 'flag' | 'saved' | 'none'
444
+ commandTimeoutMs: number
445
+ }
446
+
447
+ interface BridgeFlagState {
448
+ wsPort: number
449
+ explicitPort: boolean
450
+ commandTimeoutMs: number
451
+ simId: string | undefined
452
+ simIdSource: ParsedBridgeCliArgs['simIdSource']
453
+ }
454
+
455
+ // every flag parseBridgeCliArgs consumes a value for on its own, without being
456
+ // listed in a caller's stripValueFlags. the parser iterates this table, so the
457
+ // exported name list below cannot drift from what is actually parsed. a name
458
+ // missing from that list is not a parse failure, it is a command that hunts a
459
+ // positional in raw argv and takes the flag's value instead: `maestro test
460
+ // --newflag value login.yaml` would run `value` as the flow.
461
+ const BRIDGE_VALUE_FLAGS_TABLE: readonly {
462
+ names: readonly string[]
463
+ apply: (state: BridgeFlagState, value: string | undefined) => void
464
+ }[] = [
465
+ {
466
+ names: ['--port', '-p'],
467
+ // naming the flag pins the world even when the value is missing or junk,
468
+ // so a later --base-url cannot quietly retarget the run to another shell.
469
+ apply: (state, value) => {
470
+ state.explicitPort = true
471
+ if (value !== undefined) state.wsPort = Number(value)
472
+ },
473
+ },
474
+ {
475
+ names: ['--timeout'],
476
+ apply: (state, value) => {
477
+ if (value !== undefined) state.commandTimeoutMs = Number(value)
478
+ },
479
+ },
480
+ {
481
+ // `--sim` is the canonical target flag; `--session` / `--tab` are
482
+ // documented aliases (CLAUDE.md, the rnx agent skills, and engine docs all
483
+ // say `--session <id>`). without these aliases an agent following the docs
484
+ // would have `--session ef` silently ignored and get the *currently
485
+ // pinned* sim's tree instead — wrong-app data with no error.
486
+ names: ['--sim', '--session', '--tab'],
487
+ apply: (state, value) => {
488
+ if (value === undefined) return
489
+ state.simId = value.trim() || undefined
490
+ state.simIdSource = 'flag'
491
+ },
492
+ },
493
+ ]
494
+
495
+ export const BRIDGE_VALUE_FLAGS: readonly string[] = BRIDGE_VALUE_FLAGS_TABLE.flatMap(
496
+ (flag) => [...flag.names],
497
+ )
498
+
499
+ export function parseBridgeCliArgs(
500
+ args: string[],
501
+ opts: ParseBridgeCliArgsOptions = {},
502
+ ): ParsedBridgeCliArgs {
503
+ const flagIndices = new Set<number>()
504
+ const state: BridgeFlagState = {
505
+ wsPort: opts.port ?? resolveDefaultBridgePort(),
506
+ explicitPort: opts.port !== undefined,
507
+ commandTimeoutMs: opts.commandTimeoutMs ?? 15000,
508
+ simId: undefined,
509
+ simIdSource: 'none',
510
+ }
511
+
512
+ const stripBooleanFlags = new Set(opts.stripBooleanFlags ?? [])
513
+ const stripValueFlags = new Set(opts.stripValueFlags ?? [])
514
+
515
+ for (let i = 0; i < args.length; i++) {
516
+ const arg = args[i]
517
+ // both spellings for every flag in the table: `--flag value` and
518
+ // `--flag=value`.
519
+ const valueFlag = BRIDGE_VALUE_FLAGS_TABLE.find((flag) =>
520
+ flag.names.some((name) => arg === name || arg.startsWith(`${name}=`)),
521
+ )
522
+ if (valueFlag) {
523
+ flagIndices.add(i)
524
+ const inline = valueFlag.names.find((name) => arg.startsWith(`${name}=`))
525
+ if (inline) {
526
+ valueFlag.apply(state, arg.slice(inline.length + 1))
527
+ continue
528
+ }
529
+ const value = i + 1 < args.length ? args[i + 1] : undefined
530
+ if (value !== undefined) flagIndices.add(i + 1)
531
+ valueFlag.apply(state, value)
532
+ i++
533
+ continue
534
+ }
535
+ if (stripBooleanFlags.has(arg)) {
536
+ flagIndices.add(i)
537
+ continue
538
+ }
539
+ if (stripValueFlags.has(arg)) {
540
+ flagIndices.add(i)
541
+ if (i + 1 < args.length) {
542
+ flagIndices.add(i + 1)
543
+ }
544
+ i++
545
+ }
546
+ }
547
+
548
+ if (!state.simId) {
549
+ const savedSim = readCurrentSim()
550
+ if (savedSim) {
551
+ state.simId = savedSim.simId.trim() || undefined
552
+ state.simIdSource = 'saved'
553
+ }
554
+ }
555
+
556
+ return {
557
+ positional: args.filter((_, i) => !flagIndices.has(i)),
558
+ wsPort: state.wsPort,
559
+ explicitPort: state.explicitPort,
560
+ simId: state.simId,
561
+ simIdSource: state.simIdSource,
562
+ commandTimeoutMs: state.commandTimeoutMs,
563
+ }
564
+ }
565
+
566
+ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBridge {
567
+ let nextId = 1
568
+ const commandTimeoutMs = opts.commandTimeoutMs ?? 15000
569
+ const pending = new Map<
570
+ number,
571
+ { resolve: (v: any) => void; reject: (e: Error) => void }
572
+ >()
573
+
574
+ const ws = new WebSocket(bridgeWsUrl(wsPort))
575
+ // always key the lease on the stable agent/terminal identity, never on the
576
+ // target simId. keying off `sim:<id>` conflated "which sim" with "who is
577
+ // driving it": a single agent's identity then flipped between its stable key
578
+ // (untargeted commands) and `sim:<id>` (targeted commands), so the agent
579
+ // could take a lease under one key and get refused under the other — locked
580
+ // out of a sim it owns. it also made two different agents driving the same
581
+ // sim collide into one identity and trample each other's lease. getCliIdentity()
582
+ // is already stable per agent run (CLAUDE_CODE_SESSION_ID / per-terminal) and
583
+ // distinct between agents — exactly what the lease wants.
584
+ const identity = getCliIdentity()
585
+
586
+ const ready = new Promise<void>((resolve, reject) => {
587
+ ws.on('open', () => {
588
+ // identify ourselves so the server keys leases on the agent run,
589
+ // not the ephemeral ws connection (CLI invocations are short-lived).
590
+ // when identity is unstable the server falls back to a per-socket key,
591
+ // which is still safer than letting multiple agents share a sim.
592
+ try {
593
+ ws.send(
594
+ JSON.stringify({
595
+ type: 'bridge:hello',
596
+ id: 0,
597
+ cliIdentityKey: identity.key,
598
+ cliIdentitySource: identity.source,
599
+ cliLabel: opts.cliLabel,
600
+ }),
601
+ )
602
+ } catch {}
603
+ resolve()
604
+ })
605
+ ws.on('error', (err) => {
606
+ // err.message is often empty (ECONNREFUSED surfaces with no message on
607
+ // some node builds) — don't emit a dangling "...7668: " with no cause.
608
+ const cause = err.message ? `: ${err.message}` : ''
609
+ reject(new Error(`could not connect to ${bridgeWsUrl(wsPort)}${cause}`))
610
+ })
611
+ })
612
+ // a bridge closed before its handshake finished rejects `ready` with nothing
613
+ // awaiting it (every send awaits ready and still sees the rejection); mark it
614
+ // handled so that path never surfaces as an unhandled rejection.
615
+ ready.catch(() => {})
616
+
617
+ let warnedAboutContention = false
618
+ let noticedTarget = false
619
+ // the browser host and connection behind each target, learned while that
620
+ // sim is still connected. a reload changes the connection, and sometimes the
621
+ // sim id, but not the host.
622
+ const lineageBySimId = new Map<string, { hostPid: number; connectedAt: number }>()
623
+ const lineageLookupBySimId = new Map<string, Promise<void>>()
624
+
625
+ // a reload retires the socket but keeps the browser host. the replacement
626
+ // can receive a new sim id or restore the prior one from session storage.
627
+ // the unloading page's socket stays listed as open until its close lands, so
628
+ // only a connection newer than the known one is a replacement. the restored
629
+ // id is definitive; a new id must be the only newer same-host match.
630
+ function pickReloadedTarget(sims: unknown, retiredSimId: string): string | null {
631
+ const lineage = lineageBySimId.get(retiredSimId)
632
+ if (!lineage) return null
633
+ const matches = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter(
634
+ (sim) =>
635
+ sim.readyState === 'open' &&
636
+ sim.meta?.sootsimHostPid === lineage.hostPid &&
637
+ sim.connectedAt > lineage.connectedAt,
638
+ )
639
+ const successor =
640
+ matches.find((sim) => sim.id === retiredSimId) ??
641
+ (matches.length === 1 ? matches[0] : null)
642
+ if (!successor) return null
643
+ lineageBySimId.set(successor.id, {
644
+ hostPid: lineage.hostPid,
645
+ connectedAt: successor.connectedAt,
646
+ })
647
+ return successor.id
648
+ }
649
+
650
+ // print a one-line notice on first sim-scoped command so agents/users see
651
+ // which sim the command actually hit. suppressed for bridge:*
652
+ // control commands (list/open/focus/close/claim) that don't target a
653
+ // single sim, and for the hidden SOOTSIM_QUIET_TARGET_NOTICE escape hatch
654
+ // used by scripts that prefer clean stdout.
655
+ function maybeNoticeTarget(cmdType: string, simId: string | undefined) {
656
+ if (noticedTarget) return
657
+ if (process.env.SOOTSIM_QUIET_TARGET_NOTICE === '1') return
658
+ if (cmdType.startsWith('bridge:') || cmdType === 'focus' || cmdType === 'close')
659
+ return
660
+ noticedTarget = true
661
+ const label = simId ?? 'primary'
662
+ const source = describeSimIdSource(simId ? opts.simIdSource : 'none')
663
+ process.stderr.write(` → ${label} (${source})\n`)
664
+ }
665
+
666
+ ws.on('message', (data) => {
667
+ let msg: any
668
+ try {
669
+ msg = JSON.parse(data.toString())
670
+ } catch {
671
+ return
672
+ }
673
+ if (msg.id === 0) return // bridge:hello ack
674
+ const p = pending.get(msg.id)
675
+ if (!p) return
676
+ pending.delete(msg.id)
677
+
678
+ // warn once if another CLI identity is also targeting this sim. this is
679
+ // NOT a bridge-throughput / congestion warning — the bridge is a thin ws
680
+ // relay and handles many messages/sec. it's a *semantic* warning: two
681
+ // agents driving the same screen will collide on taps/scroll/keyboard
682
+ // state. writes already serialize via the cli lease; reads pass through.
683
+ if (msg._otherCliCount > 0 && !warnedAboutContention) {
684
+ warnedAboutContention = true
685
+ process.stderr.write(
686
+ `\n ⚠ ${msg._otherCliCount} other CLI identity/identities are driving this sim\n` +
687
+ ' taps, scrolls, and keyboard input from multiple agents will collide on\n' +
688
+ ' the same screen state (this is not a bridge throughput limit).\n' +
689
+ ' use `rnx open --new` for an isolated sim per agent.\n\n',
690
+ )
691
+ }
692
+
693
+ if (msg.error) {
694
+ if (msg._locked) {
695
+ p.reject(new BridgeSimLockedError(msg.error, msg._locked as BridgeLockInfo))
696
+ } else {
697
+ p.reject(new Error(msg.error))
698
+ }
699
+ } else {
700
+ p.resolve(msg.result)
701
+ }
702
+ })
703
+
704
+ // when the bridge host or target sim disconnects, reject all pending commands
705
+ // so the CLI gets a clear error instead of silently timing out
706
+ ws.on('close', (code, reason) => {
707
+ const msg = reason?.toString() || 'connection closed'
708
+ for (const [id, p] of pending) {
709
+ pending.delete(id)
710
+ p.reject(new Error(`sim disconnected: ${msg} (code ${code})`))
711
+ }
712
+ })
713
+
714
+ return {
715
+ plane: 'local',
716
+ async send(cmd, callOpts) {
717
+ // NOTE: `opts` (the createBridge closure arg) carries simId; do not
718
+ // shadow it with the per-call arg.
719
+ const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
720
+ const sendOnce = async (
721
+ defaultSimId?: string,
722
+ cmdOverride?: Record<string, any>,
723
+ ) => {
724
+ await ready
725
+ const id = nextId++
726
+ return new Promise((resolve, reject) => {
727
+ const timeout = setTimeout(() => {
728
+ pending.delete(id)
729
+ reject(
730
+ new Error(
731
+ `command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
732
+ ),
733
+ )
734
+ }, effectiveTimeoutMs)
735
+
736
+ pending.set(id, {
737
+ resolve: (v) => {
738
+ clearTimeout(timeout)
739
+ resolve(v)
740
+ },
741
+ reject: (e) => {
742
+ clearTimeout(timeout)
743
+ reject(e)
744
+ },
745
+ })
746
+
747
+ const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
748
+ if (payload.simId === undefined && defaultSimId) {
749
+ payload.simId = defaultSimId
750
+ }
751
+ maybeNoticeTarget(payload.type ?? '', payload.simId)
752
+ ws.send(JSON.stringify(payload))
753
+ })
754
+ }
755
+
756
+ // re-resolve the saved pin per send for bridges that were not
757
+ // explicitly `--sim`-targeted. a poll loop (`wait ready`) started
758
+ // before a concurrent `rnx open` finished connecting would
759
+ // otherwise stay on the primary fallback for its whole budget, probing
760
+ // an unrelated (often empty) sim while the intended one connects and
761
+ // pins itself mid-wait. falling back to the startup pin keeps the
762
+ // fail-closed saved-sim-gone path below when the pin file is cleared.
763
+ const sentSimId =
764
+ opts.simIdSource === 'flag' ? opts.simId : (readCurrentSimId() ?? opts.simId)
765
+ const targetedSimId =
766
+ typeof cmd.simId === 'string' && cmd.simId ? cmd.simId : sentSimId
767
+ try {
768
+ // a command can itself reload the page and retire its sim id. capture
769
+ // the browser-host lineage before dispatch, while the target still
770
+ // exists, so the caller can follow the replacement without guessing.
771
+ if (
772
+ targetedSimId &&
773
+ cmd.type !== 'bridge:list-sims' &&
774
+ !lineageBySimId.has(targetedSimId)
775
+ ) {
776
+ let lookup = lineageLookupBySimId.get(targetedSimId)
777
+ if (!lookup) {
778
+ lookup = (async () => {
779
+ try {
780
+ const sims = await sendOnce(undefined, { type: 'bridge:list-sims' })
781
+ if (!Array.isArray(sims)) return
782
+ const targeted = (sims as BridgeSimInfo[]).find(
783
+ (sim) => sim.id === targetedSimId,
784
+ )
785
+ const hostPid = targeted?.meta?.sootsimHostPid
786
+ if (targeted && typeof hostPid === 'number') {
787
+ lineageBySimId.set(targetedSimId, {
788
+ hostPid,
789
+ connectedAt: targeted.connectedAt,
790
+ })
791
+ }
792
+ } catch {
793
+ // lineage enables fail-closed reload following, but a failed
794
+ // observation must not replace the caller's command failure.
795
+ } finally {
796
+ lineageLookupBySimId.delete(targetedSimId)
797
+ }
798
+ })()
799
+ lineageLookupBySimId.set(targetedSimId, lookup)
800
+ }
801
+ await lookup
802
+ }
803
+ return await sendOnce(sentSimId)
804
+ } catch (error) {
805
+ const message = error instanceof Error ? error.message : String(error)
806
+ const missingSimMessage = `no sim connected with id ${sentSimId}`
807
+ if (
808
+ opts.simIdSource !== 'flag' &&
809
+ sentSimId &&
810
+ cmd.simId === undefined &&
811
+ (message === missingSimMessage || message.startsWith(`${missingSimMessage};`))
812
+ ) {
813
+ // a reload retires the sim id but keeps the browser host, so the
814
+ // successor with the same host pid IS the same page lineage — not a
815
+ // guess. adopt only that, and only when it is unambiguous.
816
+ const successor = pickReloadedTarget(
817
+ await sendOnce(undefined, { type: 'bridge:list-sims' }),
818
+ sentSimId,
819
+ )
820
+ if (successor) {
821
+ saveCurrentSimId(successor)
822
+ process.stderr.write(
823
+ ` note: sim ${sentSimId} reloaded and reconnected as ${successor}; following it\n`,
824
+ )
825
+ return sendOnce(successor)
826
+ }
827
+ clearCurrentSimId()
828
+ // the saved sim is gone and nothing proves what replaced it. fail
829
+ // closed instead of driving whichever sim is primary; that is how
830
+ // agents end up recording the wrong app.
831
+ throw new Error(
832
+ `saved sim ${sentSimId} is gone${message.slice(missingSimMessage.length)}; ` +
833
+ 'run `rnx list` and ' +
834
+ `\`rnx use <sim>\`, or open a fresh sim`,
835
+ )
836
+ }
837
+ throw error
838
+ }
839
+ },
840
+ async listSims() {
841
+ const sims = await this.send({ type: 'bridge:list-sims' })
842
+ return Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []
843
+ },
844
+ // a caller holding its own pinned sim id (a flow runner sets simId on every
845
+ // command, so it never reaches the pin-following path in send) asks here
846
+ // after a reload retires its socket. the page can reconnect under a new id
847
+ // or restore the old id; null means no unique same-host target exists.
848
+ async resolveReloadedSim(retiredSimId: string) {
849
+ return pickReloadedTarget(await this.listSims(), retiredSimId)
850
+ },
851
+ async focusSim(simId?: string) {
852
+ return this.send({ type: 'focus', simId })
853
+ },
854
+ async closeSim(simId?: string) {
855
+ return this.send({ type: 'close', simId })
856
+ },
857
+ async claim(simId?: string, claimOpts: { force?: boolean } = {}) {
858
+ const result = await this.send({
859
+ type: 'bridge:claim',
860
+ simId,
861
+ force: claimOpts.force === true,
862
+ })
863
+ return result as BridgeClaimResult
864
+ },
865
+ close() {
866
+ // send an explicit goodbye so the server deregisters this socket
867
+ // synchronously instead of waiting on the tcp close event, which
868
+ // can lag the next cli invocation and produce phantom-peer warnings.
869
+ try {
870
+ if (ws.readyState === WebSocket.OPEN) {
871
+ ws.send(JSON.stringify({ type: 'bridge:bye', id: 0 }))
872
+ }
873
+ } catch {}
874
+ ws.close()
875
+ // clear the grace timer on a real close, or a one-shot `rnx` command is
876
+ // held on the event loop for the full 250ms after its work is done. that
877
+ // delay lands directly in in-box prompt latency, since the shell spawns
878
+ // rnx and waits on child close.
879
+ const terminateGrace = setTimeout(() => {
880
+ if (ws.readyState !== WebSocket.CLOSED) {
881
+ ws.terminate()
882
+ }
883
+ }, 250)
884
+ ws.once('close', () => clearTimeout(terminateGrace))
885
+ },
886
+ }
887
+ }
888
+
889
+ /**
890
+ * how a command reaches a simulator. left out, it resolves the way the host
891
+ * CLI always has: the cloud box route when a box session is set, otherwise the
892
+ * local ws daemon. a box shell passes its own bridge so the same command runs
893
+ * in the calling process against the box's own simulator.
894
+ */
895
+ export interface BridgeTransportOption {
896
+ createBridge?: (parsed: ParsedBridgeCliArgs) => WsBridge
897
+ }
898
+
899
+ export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge {
900
+ const cloudBridge = createCloudBridgeForParsed(parsed)
901
+ if (cloudBridge) return cloudBridge
902
+ return createBridge(parsed.wsPort, {
903
+ commandTimeoutMs: parsed.commandTimeoutMs,
904
+ simId: parsed.simId,
905
+ simIdSource: parsed.simIdSource,
906
+ })
907
+ }
908
+
909
+ interface SimHealthProbe {
910
+ hidden?: boolean
911
+ visibilityState?: string
912
+ hasFocus?: boolean | null
913
+ simId?: string | null
914
+ url?: string
915
+ }
916
+
917
+ function describeVisibleSimCandidate(sims: BridgeSimInfo[], currentId?: string | null) {
918
+ const open = sims.filter((sim) => sim.readyState === 'open' && sim.id !== currentId)
919
+ const candidate =
920
+ open.find((sim) => sim.userFocused) ??
921
+ open.find((sim) => sim.userVisible === true && sim.isPrimary) ??
922
+ open.find((sim) => sim.userVisible === true) ??
923
+ open.find((sim) => sim.isPrimary)
924
+ if (!candidate) return null
925
+ const tags = [
926
+ candidate.isPrimary ? 'primary' : null,
927
+ candidate.userVisible === false
928
+ ? 'hidden'
929
+ : candidate.userVisible === true
930
+ ? 'visible'
931
+ : null,
932
+ candidate.userFocused ? 'focused' : null,
933
+ ].filter(Boolean)
934
+ return `${candidate.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`
935
+ }
936
+
937
+ /**
938
+ * Check if the target sim is healthy for interaction. Warns on stderr if:
939
+ * - document.hidden is true (animations won't run, coordinates may be wrong)
940
+ * Returns the health state so callers can decide whether to proceed.
941
+ */
942
+ export async function checkSimHealth(
943
+ bridge: WsBridge,
944
+ ): Promise<{ hidden: boolean; warned: boolean; simId?: string | null }> {
945
+ // a CPU simulator has no document to be hidden, and the probe below is an
946
+ // `evaluate` the cloud runtime refuses by name.
947
+ if (bridge.plane === 'cloud') return { hidden: false, warned: false }
948
+ try {
949
+ const probe = (await bridge.send({
950
+ type: 'evaluate',
951
+ code: `(() => ({
952
+ hidden: document.hidden,
953
+ visibilityState: document.visibilityState,
954
+ hasFocus: typeof document.hasFocus === 'function' ? document.hasFocus() : null,
955
+ simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null,
956
+ url: location.href
957
+ }))()`,
958
+ })) as SimHealthProbe | boolean | null
959
+ const health =
960
+ typeof probe === 'object' && probe !== null ? probe : { hidden: probe === true }
961
+ const hidden = health.hidden === true
962
+ if (hidden === true) {
963
+ let candidate: string | null = null
964
+ try {
965
+ candidate = describeVisibleSimCandidate(await bridge.listSims(), health.simId)
966
+ } catch {}
967
+ process.stderr.write(
968
+ `\n ⚠ target sim${health.simId ? ` ${health.simId}` : ''} is hidden (document.hidden = true)\n` +
969
+ (health.visibilityState ? ` visibility: ${health.visibilityState}\n` : '') +
970
+ ' animations and rAF callbacks are throttled — coordinates may be wrong\n' +
971
+ ' and launch/transition animations will not complete.\n' +
972
+ (candidate
973
+ ? ` another open sim looks usable: ${candidate}; target it with --sim.\n`
974
+ : ' run `rnx list` to find the visible sim and target it with --sim.\n') +
975
+ ' page-level window.focus() cannot unhide a browser tab.\n\n',
976
+ )
977
+ return { hidden: true, warned: true, simId: health.simId }
978
+ }
979
+ return { hidden: false, warned: false, simId: health.simId }
980
+ } catch {
981
+ return { hidden: false, warned: false }
982
+ }
983
+ }
984
+
985
+ /**
986
+ * Convenience: run arbitrary JavaScript in the rnx page and return the
987
+ * result. Hides the `type: 'evaluate'` message shape. Read-only by default —
988
+ * pass `{ acquireLock: true }` when the eval mutates user-facing state so
989
+ * the server takes/refreshes the cli lease and blocks concurrent agents.
990
+ */
991
+ export async function evalInBridge<T = unknown>(
992
+ bridge: WsBridge,
993
+ code: string,
994
+ opts: { acquireLock?: boolean } = {},
995
+ ): Promise<T> {
996
+ const payload: { type: string; [k: string]: any } = { type: 'evaluate', code }
997
+ if (opts.acquireLock) payload.acquireLock = true
998
+ return bridge.send(payload) as Promise<T>
999
+ }
1000
+
1001
+ /**
1002
+ * Invoke a function on `window.SootSim.bridges.*` over the bridge. Read-only
1003
+ * by default for the same reason as evalInBridge.
1004
+ */
1005
+ export async function callInBridge<T = unknown>(
1006
+ bridge: WsBridge,
1007
+ path: string,
1008
+ ...args: unknown[]
1009
+ ): Promise<T> {
1010
+ return bridge.send({ type: 'call', path, args }) as Promise<T>
1011
+ }
1012
+
1013
+ /** Write variant — takes/refreshes the cli lease on the target sim. */
1014
+ export async function callInBridgeWrite<T = unknown>(
1015
+ bridge: WsBridge,
1016
+ path: string,
1017
+ ...args: unknown[]
1018
+ ): Promise<T> {
1019
+ return bridge.send({ type: 'call', path, args, acquireLock: true }) as Promise<T>
1020
+ }