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,2788 @@
1
+ import { spawn } from 'child_process'
2
+ import fs from 'fs'
3
+ import { createServer, type IncomingMessage, type ServerResponse } from 'http'
4
+ import path from 'path'
5
+ import { WebSocket, WebSocketServer } from 'ws'
6
+ import {
7
+ scanDevServers,
8
+ type DiscoveredServer,
9
+ } from '../../scripts/dev-server-scanner.ts'
10
+ import { buildBrowserAssetProxyUrl } from '../backend-origin.ts'
11
+ import {
12
+ DEFAULT_SOOTSIM_BRIDGE_PORT,
13
+ SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
14
+ SOOTSIM_BRIDGE_SIM_CLOSE_REASON,
15
+ } from '../bridge-constants.ts'
16
+ import { getCliVersion } from '../cli-version.ts'
17
+ import {
18
+ activeRuntimeDir as getActiveRuntimeDir,
19
+ claimDaemonLockfile,
20
+ ensureRnxHome,
21
+ isSootsimDevCheckout,
22
+ readActiveRuntime,
23
+ readSharedConfig,
24
+ removeDaemonLockfile,
25
+ removeDevBridgeLockfile,
26
+ writeActiveRuntime,
27
+ writeDaemonLockfile,
28
+ writeDevBridgeLockfile,
29
+ writeRuntimeUpgradeNotice,
30
+ writeSharedConfig,
31
+ cameraFixturesDir,
32
+ rnxHomeDir,
33
+ type DaemonLockfile,
34
+ type DevBridgeLockfile,
35
+ type SharedConfig,
36
+ } from '../home-paths.ts'
37
+ import { rnxPublicBrand } from '../public-brand.ts'
38
+ import {
39
+ isRuntimeVersionHostname,
40
+ resolveDevCheckoutRuntimeRoot,
41
+ resolveRuntimeRootForHostname,
42
+ runtimeVersionFromHostname,
43
+ } from '../runtime-assets.ts'
44
+ import { rnxRuntime } from '../runtime-delivery.ts'
45
+ import { AgentHost, type AgentHostOptions } from './agent-host.ts'
46
+ import {
47
+ handleAppApiRequest,
48
+ handleBundleProxyRequest,
49
+ handleFetchProxyRequest,
50
+ isAppApiRequestUrl,
51
+ isFetchProxyRequestUrl,
52
+ } from './fetch-proxy-handler.ts'
53
+ import { openUrl as openUrlInBrowser, type OpenUrlOptions } from './open-url.ts'
54
+ import { PlaneHost } from './plane-host.ts'
55
+ import {
56
+ handleReplacementModuleRequest,
57
+ isReplacementModuleRequestUrl,
58
+ } from './replacement-module-handler.ts'
59
+ import { handleWebSocketProxyUpgrade } from './websocket-proxy.ts'
60
+ import type { WsCommand } from '../bridge-contract.ts'
61
+
62
+ const SOOTSIM_CROSS_ORIGIN_EMBEDDER_POLICY = 'require-corp'
63
+
64
+ export interface BridgeSimInfo {
65
+ id: string
66
+ origin?: string
67
+ url?: string
68
+ title?: string
69
+ userAgent?: string
70
+ connectedAt: number
71
+ lastSeenAt: number
72
+ lastActiveAt?: number
73
+ isPrimary: boolean
74
+ readyState: 'open' | 'closing' | 'closed'
75
+ attachedCliCount?: number
76
+ lockedBy?: string
77
+ lockedByKind?: 'cli' | 'user-active'
78
+ lockExpiresAt?: number
79
+ // true when the lease belongs to the caller asking for this list. the
80
+ // server computes it because `lockedBy` is a display string (a cliLabel
81
+ // like "open --describe", or "active user") and cannot be compared
82
+ // against an identity key by the client.
83
+ lockedByMe?: boolean
84
+ userFocused?: boolean
85
+ userVisible?: boolean
86
+ visibilityState?: string
87
+ documentFocused?: boolean
88
+ /** registration "kind" — lets a single daemon host multiple surface
89
+ * types. omitted / unknown defaults to 'sootsim'. the contrast web
90
+ * IDE registers with kind='contrast'; CLI consumers filter by this
91
+ * rather than running a parallel bridge. */
92
+ kind?: string
93
+ /** opaque metadata supplied at register time (e.g. projectId, route,
94
+ * attached iOS sim id for a contrast tab). free-form by design — the
95
+ * daemon does not interpret it, only stores + reports it back. */
96
+ meta?: Record<string, unknown>
97
+ }
98
+
99
+ export interface BridgeLockInfo {
100
+ by: string
101
+ expiresInMs: number
102
+ }
103
+
104
+ export type BridgeSimCommand = Omit<WsCommand, 'id'> & { id: number }
105
+
106
+ interface BridgeSimRegistrationMessage {
107
+ type: 'bridge:register'
108
+ simId?: string
109
+ url?: string
110
+ title?: string
111
+ userAgent?: string
112
+ /** see BridgeSimInfo.kind — defaults to 'sootsim' when omitted. */
113
+ kind?: string
114
+ /** see BridgeSimInfo.meta — free-form metadata for filtering/routing. */
115
+ meta?: Record<string, unknown>
116
+ }
117
+
118
+ interface BridgeSimUserFocusStateMessage {
119
+ type: 'bridge:user-focus-state'
120
+ focused?: boolean
121
+ visible?: boolean
122
+ visibilityState?: string
123
+ documentFocused?: boolean
124
+ }
125
+
126
+ interface BridgeSimClientStateMessage {
127
+ type: 'bridge:client-state'
128
+ attachedCliCount: number
129
+ activeAgentCommandCount: number
130
+ recentActions: BridgeRecentAction[]
131
+ lockedBy?: string
132
+ lockedByKind?: 'cli' | 'user-active'
133
+ lockExpiresAt?: number
134
+ userFocused?: boolean
135
+ userVisible?: boolean
136
+ visibilityState?: string
137
+ documentFocused?: boolean
138
+ }
139
+
140
+ interface BridgeRecentAction {
141
+ label: string
142
+ at: number
143
+ }
144
+
145
+ interface BridgeSimConnection {
146
+ id: string
147
+ ws: WebSocket
148
+ origin?: string
149
+ url?: string
150
+ title?: string
151
+ userAgent?: string
152
+ connectedAt: number
153
+ lastSeenAt: number
154
+ lastActiveAt: number
155
+ recentActions: BridgeRecentAction[]
156
+ cliLease?: BridgeCliLease
157
+ userFocused?: boolean
158
+ userVisible?: boolean
159
+ visibilityState?: string
160
+ documentFocused?: boolean
161
+ /** see BridgeSimInfo.kind. */
162
+ kind?: string
163
+ /** see BridgeSimInfo.meta. */
164
+ meta?: Record<string, unknown>
165
+ }
166
+
167
+ interface BridgeCliLease {
168
+ kind: 'cli' | 'user-active'
169
+ cliIdentityKey: string
170
+ cliLabel?: string
171
+ expiresAt: number
172
+ }
173
+
174
+ // interactive commands change app state and must respect the lease.
175
+ // observational/lifecycle commands (evaluate/tree/query/resolve/screenshot/capture/focus/call/close)
176
+ // pass through without taking or checking a lease so cleanup never gets stuck
177
+ // behind a stale cli owner.
178
+ // `call` used to acquire a lease too, but most call paths are queries
179
+ // (`__sootsimTest.findByTestId`, state reads, etc.). the CLI opts write
180
+ // calls into lease acquisition via msg.acquireLock=true.
181
+ const WRITE_COMMAND_TYPES = new Set([
182
+ 'tap',
183
+ 'keyboard',
184
+ 'longPress',
185
+ 'perform',
186
+ 'reset',
187
+ 'camera',
188
+ ])
189
+
190
+ // how long a `close` waits for the sim page to tear itself down before the
191
+ // host disconnects the sim socket. the close code is part of the protocol:
192
+ // browser clients treat it as terminal and skip their normal reconnect loop.
193
+ const FORCE_CLOSE_GRACE_MS = 2000
194
+ const FORCE_CLOSE_TERMINATE_MS = 1000
195
+
196
+ // note: `unrefTimer` is declared once, below (with the `UnrefableTimer`
197
+ // type). a duplicate copy used to live here from a concurrent edit and
198
+ // broke the standalone CLI esbuild bundle ("symbol already declared").
199
+
200
+ function shouldAcquireLease(msg: any): boolean {
201
+ if (!msg || typeof msg.type !== 'string') return false
202
+ if (msg.acquireLock === true) return true
203
+ if (msg.readOnly === true) return false
204
+ return WRITE_COMMAND_TYPES.has(msg.type)
205
+ }
206
+
207
+ interface BridgeRestorableSimState {
208
+ recentActions: BridgeRecentAction[]
209
+ lastActiveAt: number
210
+ cliLease?: BridgeCliLease
211
+ expiresAt: number
212
+ }
213
+
214
+ interface BridgePendingCommand {
215
+ simId: string
216
+ resolve: (value: any) => void
217
+ reject: (error: Error) => void
218
+ }
219
+
220
+ interface BridgeForwardedCommand {
221
+ simId: string
222
+ ws: WebSocket
223
+ originalId: number | string
224
+ }
225
+
226
+ export interface BridgeHostOptions {
227
+ /** use 0 for an OS-assigned port; startAsync returns the bound address. */
228
+ port?: number
229
+ openUrl?: (url: string, options?: OpenUrlOptions) => Promise<void> | void
230
+ /** Contrast-specific excludes for the agent host's dev-server scan. passed
231
+ * through to AgentHost; see packages/sootsim-engine/src/dev-scan-excludes.ts
232
+ * for the canonical list. when omitted, AgentHost reads env vars directly. */
233
+ agentScanExcludes?: AgentHostOptions['getExcludePorts']
234
+ /** try preferred port, then port+1, port+2, …, up to this many attempts
235
+ * before giving up. defaults to 10. set 1 to disable fallback. */
236
+ portFallbackCount?: number
237
+ /** when true, write ~/.rnx/daemon.json on successful bind + update
238
+ * it on a heartbeat interval + remove it on close. only the standalone
239
+ * `rnx serve` process should set this — tests, vite plugin, and
240
+ * anything embedded should leave it off to avoid clobbering a real
241
+ * daemon's lockfile. defaults to false. */
242
+ writeLockfile?: boolean
243
+ /** when true, write an owner-scoped record under ~/.rnx/dev-bridges on
244
+ * successful bind, update it on a heartbeat interval, and remove only that
245
+ * record on close. the Vite dev shell sets this so local CLI commands can
246
+ * address every live source bridge without concurrent worktrees replacing
247
+ * one shared pointer. */
248
+ writeDevLockfile?: boolean
249
+ /** resolve the http port of the vite shell dev server that hosts this
250
+ * bridge (e.g. 5173). called on every dev-lockfile write so the port
251
+ * fills in once the vite server is actually listening. only meaningful
252
+ * with writeDevLockfile. */
253
+ getShellPort?: () => number | null
254
+ /** the contrast origin this daemon's runtimes should talk to for auth,
255
+ * billing, and preview uploads — inlined into served runtime html as
256
+ * `window.__sootsimContrastOrigin`. without it the engine's origin.ts
257
+ * resolves the daemon's own loopback host (localhost:<runtimePort>) to
258
+ * the dev `contrast.localhost:3000` stack, so a *prod* CLI user's preview
259
+ * recording would upload to a localhost stack that doesn't exist. the
260
+ * `rnx serve` command resolves this with the same probe the
261
+ * upload itself uses (`resolveDefaultUploadOrigin`). */
262
+ contrastOrigin?: string
263
+ /** override the abandoned-sim GC TTL (ms). defaults to
264
+ * SIM_IDLE_REAP_TTL_MS (30 min). exists so the reaper integration test
265
+ * can drive the real timer path deterministically instead of mocking
266
+ * time. production callers should never set this. */
267
+ simIdleReapTtlMs?: number
268
+ /** override the idle GC TTL (ms) for automation sims (playwright-driver,
269
+ * capture/factory). defaults to AUTOMATION_SIM_IDLE_REAP_TTL_MS (10 min).
270
+ * test-only, like simIdleReapTtlMs. */
271
+ automationSimIdleReapTtlMs?: number
272
+ /** cap on concurrent non-primary automation sims before the reaper drops
273
+ * the most-idle overflow. defaults to MAX_CONCURRENT_AUTOMATION_SIMS (6).
274
+ * test-only. */
275
+ maxConcurrentAutomationSims?: number
276
+ /** grace (ms) below which an automation sim is never reaped by the cap,
277
+ * even when over it. defaults to AUTOMATION_SIM_ACTIVE_GRACE_MS (60 s).
278
+ * test-only. */
279
+ automationSimActiveGraceMs?: number
280
+ }
281
+
282
+ const DAEMON_HEARTBEAT_INTERVAL_MS = 5_000
283
+ const DEFAULT_RUNTIME_UPDATE_INTERVAL_MS = 60 * 60 * 1000
284
+ const RUNTIME_UPDATE_INTERVAL_ENV = 'SOOTSIM_RUNTIME_UPDATE_INTERVAL_MS'
285
+
286
+ const HTTP_MIME_TYPES: Record<string, string> = {
287
+ '.html': 'text/html; charset=utf-8',
288
+ '.js': 'application/javascript',
289
+ '.cjs': 'application/javascript',
290
+ '.mjs': 'application/javascript',
291
+ '.css': 'text/css; charset=utf-8',
292
+ '.json': 'application/json; charset=utf-8',
293
+ '.png': 'image/png',
294
+ '.jpg': 'image/jpeg',
295
+ '.jpeg': 'image/jpeg',
296
+ '.gif': 'image/gif',
297
+ '.svg': 'image/svg+xml',
298
+ '.webp': 'image/webp',
299
+ '.avif': 'image/avif',
300
+ '.ico': 'image/x-icon',
301
+ '.wasm': 'application/wasm',
302
+ '.ttf': 'font/ttf',
303
+ '.otf': 'font/otf',
304
+ '.woff': 'font/woff',
305
+ '.woff2': 'font/woff2',
306
+ '.map': 'application/json',
307
+ '.txt': 'text/plain; charset=utf-8',
308
+ '.mp4': 'video/mp4',
309
+ '.m4v': 'video/mp4',
310
+ '.webm': 'video/webm',
311
+ '.mov': 'video/quicktime',
312
+ }
313
+
314
+ // `rnx camera play` stages a video under ~/.rnx/camera-fixtures and the
315
+ // simulator plays it as the camera feed. the route below serves that one
316
+ // directory, addressed by basename only.
317
+ const CAMERA_FIXTURE_ROUTE = '/__camera-fixtures/'
318
+ // no slashes, no dots leading a segment, no traversal to express. a name that
319
+ // does not match is rejected rather than normalized, so there is nothing to
320
+ // get the normalization wrong about.
321
+ const CAMERA_FIXTURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
322
+
323
+ /** inject host-provided globals into the served runtime html:
324
+ * - `window.__sootsimSharedConfig` — parsed `~/.rnx/config.json`,
325
+ * read during engine settingsStore module init (see
326
+ * settings/persistence.ts). mirrors the inject-into-html plugin in the
327
+ * shell vite dev server so dev and prod surfaces both produce it.
328
+ * - `window.__sootsimCliVersion` — the CLI version serving this runtime,
329
+ * so the shell can surface CLI vs runtime version (MacMenuBar footer,
330
+ * ReportBody) and users can tell what they're actually running.
331
+ * - `window.__sootsimBridgePort` — the ws bridge port this daemon is
332
+ * actually listening on. the runtime http server and the ws bridge are
333
+ * the same server on the same port, but a daemon whose preferred 7668
334
+ * was taken falls back to 7669+. without this the runtime page would
335
+ * hardcode the 7668 default in `resolveBridgePort` and register on a
336
+ * *different* daemon (or none), so `rnx open` times out waiting for
337
+ * a sim that connected to the wrong bridge.
338
+ * - `window.__sootsimContrastOrigin` — the contrast origin for auth /
339
+ * billing / preview uploads. the daemon serves runtimes from its own
340
+ * loopback host, and engine `origin.ts` otherwise resolves any
341
+ * loopback host to the dev `contrast.localhost:3000` stack — so without this a
342
+ * prod CLI user's preview recording would upload to a localhost stack
343
+ * that does not exist. */
344
+ export function injectSharedConfigIntoHtml(
345
+ data: Buffer,
346
+ bridgePort: number,
347
+ contrastOrigin: string | null,
348
+ ): string {
349
+ let payload: string
350
+ try {
351
+ const cfg: SharedConfig = readSharedConfig()
352
+ payload = JSON.stringify(cfg)
353
+ } catch {
354
+ payload = '{}'
355
+ }
356
+ const bridgePortTag = bridgePort > 0 ? `window.__sootsimBridgePort=${bridgePort};` : ''
357
+ const contrastOriginTag = contrastOrigin
358
+ ? `window.__sootsimContrastOrigin=${JSON.stringify(contrastOrigin)};`
359
+ : ''
360
+ const tag =
361
+ `<script>window.__sootsimSharedConfig=${payload};` +
362
+ bridgePortTag +
363
+ contrastOriginTag +
364
+ `window.__sootsimCliVersion=${JSON.stringify(getCliVersion())};</script>`
365
+ const html = data.toString('utf8')
366
+ if (html.includes('<head>')) return html.replace('<head>', `<head>${tag}`)
367
+ if (html.includes('</head>')) return html.replace('</head>', tag + '</head>')
368
+ if (html.includes('</body>')) return html.replace('</body>', tag + '</body>')
369
+ // no recognized injection point — prepend so the global is defined
370
+ // before any inline scripts further down in the document.
371
+ return tag + html
372
+ }
373
+
374
+ type UnrefableTimer = { unref: () => void }
375
+
376
+ function unrefTimer(timer: ReturnType<typeof setTimeout>) {
377
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
378
+ ;(timer as UnrefableTimer).unref()
379
+ }
380
+ }
381
+
382
+ export class SootSimBridgeHost {
383
+ private port: number
384
+ private openUrlHandler?: (url: string, options?: OpenUrlOptions) => Promise<void> | void
385
+ private httpServer: ReturnType<typeof createServer> | null = null
386
+ private wss: WebSocketServer | null = null
387
+ private nextCommandId = 1
388
+ private nextSimNumber = 0xa1
389
+ private sims = new Map<string, BridgeSimConnection>()
390
+ private primarySimId: string | null = null
391
+ private pendingCommands = new Map<number, BridgePendingCommand>()
392
+ private cliBySentId = new Map<number, BridgeForwardedCommand>()
393
+ private cliSimBySocket = new Map<WebSocket, string>()
394
+ private cliLastCommandAt = new Map<WebSocket, number>()
395
+ private cliIdentityKeyBySocket = new Map<WebSocket, string>()
396
+ private cliLabelBySocket = new Map<WebSocket, string>()
397
+ private restorableSims = new Map<string, BridgeRestorableSimState>()
398
+ private nextCliFallbackId = 1
399
+ private cliIdleTimer: NodeJS.Timeout | null = null
400
+ private agentHost: AgentHost
401
+ private planeHost = new PlaneHost()
402
+ private static CLI_IDLE_TIMEOUT_MS = 60_000
403
+ private static CLI_LEASE_TTL_MS = 600_000
404
+ private static USER_ACTIVE_LEASE_TTL_MS = 8_000
405
+ // explicit user actions (clicking Boot, focusing the sim to take it over)
406
+ // hold the sim longer than passive canvas interaction so reconnecting clis
407
+ // can't immediately reclaim while the user gets oriented.
408
+ private static USER_BOOT_LEASE_TTL_MS = 60_000
409
+ private static SIM_RECONNECT_TTL_MS = 30_000
410
+ // abandoned-tab GC. the ws heartbeat only reaps sims whose socket went
411
+ // dead — but a background tab from a prior `rnx open` / QA / test run
412
+ // keeps its socket alive (the page still pongs) forever, so `rnx list`
413
+ // accretes dozens of zombie sims that all time out on every command (QA
414
+ // F21-3, carried F19-2). reap a sim only when it is provably nobody's:
415
+ // not primary, not user-focused, no CLI attached, no active lease, and no
416
+ // CLI-driven activity for this long. closeSimSocketFromHost sends the
417
+ // terminal 4001 code, so the client closes the abandoned window instead
418
+ // of reconnecting the zombie straight back in.
419
+ private static SIM_IDLE_REAP_TTL_MS = 30 * 60_000
420
+ // automation sims (spawned by the playwright driver: `rnx open --driver
421
+ // playwright`, the factory/screenshot capture path — `meta.sootsimHostDriver
422
+ // === 'playwright'`) are unattended one-shot browsers, never a human's tab. a
423
+ // capture run opens one per slide/app/retry and the 30-min human/dev TTL lets
424
+ // hundreds pile up inside the window — orphaned ms-playwright chromiums that
425
+ // drove the box to swap-thrash (load hit 157 in one incident). give them a far
426
+ // shorter idle TTL, and cap how many can sit concurrently: reap the most-idle
427
+ // overflow, but never a sim active within ACTIVE_GRACE (an agent composing
428
+ // between captures), nor primary / cli-attached / leased sims.
429
+ private static AUTOMATION_SIM_IDLE_REAP_TTL_MS = 10 * 60_000
430
+ private static MAX_CONCURRENT_AUTOMATION_SIMS = 6
431
+ private static AUTOMATION_SIM_ACTIVE_GRACE_MS = 60_000
432
+
433
+ private preferredPort: number
434
+ private portFallbackCount: number
435
+ private simIdleReapTtlMs: number
436
+ private automationSimIdleReapTtlMs: number
437
+ private maxConcurrentAutomationSims: number
438
+ private automationSimActiveGraceMs: number
439
+ private shouldWriteLockfile: boolean
440
+ private shouldWriteDevLockfile: boolean
441
+ private getShellPort: (() => number | null) | null
442
+ private contrastOrigin: string | null = null
443
+ private effectivePort = 0
444
+ private startedAt = 0
445
+ private heartbeatTimer: NodeJS.Timeout | null = null
446
+ private devHeartbeatTimer: NodeJS.Timeout | null = null
447
+ // ws-level heartbeat: sims that hang up uncleanly (page navigated, network
448
+ // dropped, sim crashed) leave their server-side WebSocket sitting "open"
449
+ // forever. ping every WS_HEARTBEAT_INTERVAL_MS; if the previous round's
450
+ // ping was never answered, terminate(). that fires 'close' which runs the
451
+ // sim-cleanup path and stops `rnx list` from showing 8 zombie
452
+ // sims that all time out on every command.
453
+ private wsHeartbeatTimer: NodeJS.Timeout | null = null
454
+ private wsIsAlive = new WeakMap<WebSocket, boolean>()
455
+ private static WS_HEARTBEAT_INTERVAL_MS = 30_000
456
+ private runtimeUpdateTimer: NodeJS.Timeout | null = null
457
+ private runtimeUpdateInFlight: Promise<void> | null = null
458
+ private activeRuntimeVersion: string | null = null
459
+ private activeRuntimeDirPath: string | null = null
460
+ // /__server-scan cache. mirrors the shell vite dev-middleware so engine
461
+ // ConnectRN / DemoConnectApp see the same JSON shape whether they boot
462
+ // from vite dev or from this daemon. without this, the SPA fallback below
463
+ // would serve index.html for /__server-scan and tenant-worker .json()
464
+ // crashes with "Unexpected token '<', "<!doctype "... is not valid JSON".
465
+ private scanCache: DiscoveredServer[] | null = null
466
+ private scanCacheAt = 0
467
+ private inflightScan: Promise<DiscoveredServer[]> | null = null
468
+ private static SCAN_FRESH_MS = 2000
469
+
470
+ constructor(opts: BridgeHostOptions = {}) {
471
+ this.preferredPort = opts.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT
472
+ this.port = this.preferredPort
473
+ // default off — callers that want the lockfile (rnx serve) opt in.
474
+ this.shouldWriteLockfile = opts.writeLockfile === true
475
+ this.shouldWriteDevLockfile = opts.writeDevLockfile === true
476
+ this.getShellPort = opts.getShellPort ?? null
477
+ // fallback freely on port collision regardless of who owns the lockfile.
478
+ // serve.ts already refuses to start if a fresh daemon.json exists, so
479
+ // two daemons can't race the lockfile. without this, an unrelated process
480
+ // on 7668 (another worktree's vite dev fallback bridge, etc.) was enough
481
+ // to keep the daemon from binding at all — and launchd's KeepAlive then
482
+ // respawned it forever.
483
+ this.portFallbackCount = Math.max(1, opts.portFallbackCount ?? 10)
484
+ this.openUrlHandler = opts.openUrl
485
+ this.agentHost = new AgentHost({
486
+ getExcludePorts: opts.agentScanExcludes,
487
+ resolveCliLease: (simId) => {
488
+ const sim = this.sims.get(simId)
489
+ const lease = sim ? this.getActiveLease(sim) : null
490
+ return lease
491
+ ? {
492
+ kind: lease.kind,
493
+ cliIdentityKey: lease.cliIdentityKey,
494
+ expiresAt: lease.expiresAt,
495
+ }
496
+ : null
497
+ },
498
+ })
499
+ this.contrastOrigin = opts.contrastOrigin?.replace(/\/$/, '') || null
500
+ this.simIdleReapTtlMs =
501
+ opts.simIdleReapTtlMs ?? SootSimBridgeHost.SIM_IDLE_REAP_TTL_MS
502
+ this.automationSimIdleReapTtlMs =
503
+ opts.automationSimIdleReapTtlMs ?? SootSimBridgeHost.AUTOMATION_SIM_IDLE_REAP_TTL_MS
504
+ this.maxConcurrentAutomationSims =
505
+ opts.maxConcurrentAutomationSims ?? SootSimBridgeHost.MAX_CONCURRENT_AUTOMATION_SIMS
506
+ this.automationSimActiveGraceMs =
507
+ opts.automationSimActiveGraceMs ?? SootSimBridgeHost.AUTOMATION_SIM_ACTIVE_GRACE_MS
508
+ }
509
+
510
+ /** expose the agent host so tests and embedders can inspect state or
511
+ * inject behavior. not part of the public WS protocol. */
512
+ getAgentHost(): AgentHost {
513
+ return this.agentHost
514
+ }
515
+
516
+ /** run the abandoned-sim GC pass on demand. the reaper normally fires off
517
+ * the 30s idle-sweep timer; this lets the F21-3 integration test drive
518
+ * the real path without waiting for the timer. not part of the public
519
+ * WS protocol. */
520
+ reapIdleSimsForTest(now = Date.now()): void {
521
+ this.reapIdleSims(now)
522
+ }
523
+
524
+ /** synchronous wrapper around startAsync for callers that don't care
525
+ * about port fallback outcomes. returns immediately; actual binding
526
+ * happens on the event loop. callers that need to know the bound port
527
+ * should await startAsync() instead. */
528
+ start(options?: { silent?: boolean }): void {
529
+ void this.startAsync(options)
530
+ }
531
+
532
+ async startAsync(options?: { silent?: boolean }): Promise<number> {
533
+ if (this.httpServer || this.wss) return this.effectivePort
534
+
535
+ // seed active runtime state from disk so the http routes + lockfile
536
+ // reflect reality at boot. we reread this on runtime:use messages.
537
+ this.refreshActiveRuntime()
538
+
539
+ for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
540
+ const candidate = this.preferredPort + attempt
541
+ try {
542
+ const boundPort = await this.bindOnce(candidate, options?.silent === true)
543
+ this.effectivePort = boundPort
544
+ this.port = boundPort
545
+ this.startedAt = Date.now()
546
+ if (attempt > 0 && !options?.silent) {
547
+ process.stderr.write(
548
+ `ws bridge bound to port ${candidate} (preferred ${this.preferredPort} was taken)\n`,
549
+ )
550
+ }
551
+ this.afterBind()
552
+ return boundPort
553
+ } catch (err: unknown) {
554
+ const e = err as NodeJS.ErrnoException
555
+ if (e?.code !== 'EADDRINUSE') {
556
+ throw err
557
+ }
558
+ if (!options?.silent) {
559
+ process.stderr.write(
560
+ `ws bridge port ${candidate} already in use, trying ${candidate + 1}\n`,
561
+ )
562
+ }
563
+ // bindOnce already cleaned up httpServer/wss on failure
564
+ }
565
+ }
566
+ throw new Error(
567
+ `could not bind ws bridge after ${this.portFallbackCount} attempts starting at ${this.preferredPort}`,
568
+ )
569
+ }
570
+
571
+ private bindOnce(port: number, _silent: boolean): Promise<number> {
572
+ return new Promise<number>((resolve, reject) => {
573
+ const server = createServer((req, res) => this.handleHttpRequest(req, res))
574
+ let settled = false
575
+
576
+ const onError = (err: NodeJS.ErrnoException) => {
577
+ if (settled) return
578
+ settled = true
579
+ try {
580
+ server.close()
581
+ } catch {}
582
+ this.httpServer = null
583
+ this.wss = null
584
+ reject(err)
585
+ }
586
+ server.once('error', onError)
587
+
588
+ // loopback-only bind. we listen on 127.0.0.1 explicitly because the
589
+ // daemon serves the active runtime's dist as plain static files —
590
+ // never expose that to LAN peers. tests + the electron renderer use
591
+ // 127.0.0.1 explicitly to avoid the localhost-vs-::1 DNS coinflip.
592
+ server.listen(port, '127.0.0.1', () => {
593
+ if (settled) return
594
+ const address = server.address()
595
+ if (!address || typeof address === 'string') {
596
+ onError(new Error('ws bridge has no bound TCP address'))
597
+ return
598
+ }
599
+ settled = true
600
+ server.removeListener('error', onError)
601
+ server.on('error', (err) => {
602
+ process.stderr.write(`ws bridge http error: ${String(err)}\n`)
603
+ })
604
+ this.httpServer = server
605
+ this.wss = new WebSocketServer({ noServer: true })
606
+ this.wireWebSocketServer()
607
+ server.on('upgrade', (req, socket, head) => {
608
+ if (handleWebSocketProxyUpgrade(req, socket, head)) return
609
+ this.wss?.handleUpgrade(req, socket, head, (ws) => {
610
+ this.wss?.emit('connection', ws, req)
611
+ })
612
+ })
613
+ resolve(address.port)
614
+ })
615
+ })
616
+ }
617
+
618
+ /** attach the WS connection handler to the current wss. called from
619
+ * bindOnce() after WebSocketServer is freshly created. */
620
+ private wireWebSocketServer() {
621
+ if (!this.wss) return
622
+ this.wss.on('connection', (ws, req) => {
623
+ const origin = req.headers.origin
624
+ let role: 'sim' | 'cli' = origin ? 'sim' : 'cli'
625
+ let sim: BridgeSimConnection | null = null
626
+
627
+ // ws emits 'error' for late peer-side socket faults (write EIO after
628
+ // sleep/wake, abrupt sim close, etc). without a listener, node's
629
+ // EventEmitter rethrows as uncaughtException and crashes the host.
630
+ // 'close' fires right after and runs the cleanup below.
631
+ ws.on('error', () => {})
632
+
633
+ // ws-level heartbeat. pong responses come for free from the ws
634
+ // protocol — we just need to track them so the heartbeat sweep
635
+ // (started in afterBind) can terminate connections that stop
636
+ // answering. fresh connections start alive.
637
+ this.wsIsAlive.set(ws, true)
638
+ ws.on('pong', () => {
639
+ this.wsIsAlive.set(ws, true)
640
+ })
641
+
642
+ // register the socket with the agent host so session-status pushes
643
+ // reach it even before it subscribes to any specific session, and so
644
+ // subscriptions get cleaned up automatically on close.
645
+ this.agentHost.registerSocket(ws)
646
+
647
+ if (role === 'sim') {
648
+ sim = {
649
+ id: this.allocateSimId(),
650
+ ws,
651
+ origin,
652
+ connectedAt: Date.now(),
653
+ lastSeenAt: Date.now(),
654
+ lastActiveAt: 0,
655
+ recentActions: [],
656
+ }
657
+ this.sims.set(sim.id, sim)
658
+ this.writeConnectedRuntimeSnapshot()
659
+ if (this.shouldPromoteSim(sim)) {
660
+ this.primarySimId = sim.id
661
+ }
662
+ this.broadcastSimAssignments()
663
+ this.broadcastSimClientStates()
664
+ } else {
665
+ const fallbackKey = `ws-${this.nextCliFallbackId++}`
666
+ this.cliIdentityKeyBySocket.set(ws, fallbackKey)
667
+ }
668
+
669
+ ws.on('message', (data) => {
670
+ let msg: any
671
+ try {
672
+ msg = JSON.parse(data.toString())
673
+ } catch {
674
+ return
675
+ }
676
+ if (!msg || typeof msg !== 'object') return
677
+
678
+ // agent:* messages are routed uniformly regardless of socket role.
679
+ // CLI (`sootsim agent …`), electron main (daemon client), and
680
+ // sim shells all use the same envelope, and agent event
681
+ // subscriptions work from any of them — this is the whole point of
682
+ // moving session ownership into the daemon.
683
+ if (typeof msg.type === 'string' && msg.type.startsWith('agent:')) {
684
+ void this.agentHost.handleMessage(ws, msg)
685
+ return
686
+ }
687
+
688
+ // plane:* is the project file plane, routed the same way and for the
689
+ // same reason: the CLI, a box, and a browser tab all speak one
690
+ // envelope. see plans/rnx-cloud/transport-decision.md.
691
+ if (typeof msg.type === 'string' && msg.type.startsWith('plane:')) {
692
+ void this.planeHost.handleMessage(ws, msg)
693
+ return
694
+ }
695
+
696
+ // runtime:* messages manage installed engine runtimes. list / use
697
+ // are handled in-daemon; install runs entirely inside the CLI so
698
+ // we don't need a daemon-side handler for it.
699
+ if (msg.type === 'runtime:list') {
700
+ const versions = rnxRuntime.listInstalled()
701
+ const active = this.getActiveRuntime()
702
+ const reply = {
703
+ type: 'runtime:list:ok',
704
+ id: msg.id,
705
+ installed: versions,
706
+ active: active.version,
707
+ activeRuntimeDir: active.runtimeDir,
708
+ }
709
+ try {
710
+ ws.send(JSON.stringify(reply))
711
+ } catch {}
712
+ return
713
+ }
714
+ if (msg.type === 'runtime:use') {
715
+ const version = typeof msg.version === 'string' ? msg.version : ''
716
+ const installed = rnxRuntime.listInstalled()
717
+ if (!installed.includes(version)) {
718
+ try {
719
+ ws.send(
720
+ JSON.stringify({
721
+ type: 'runtime:use:error',
722
+ id: msg.id,
723
+ error: `runtime ${version || '(missing)'} is not installed`,
724
+ }),
725
+ )
726
+ } catch {}
727
+ return
728
+ }
729
+ const result = this.setActiveRuntime(version)
730
+ try {
731
+ ws.send(
732
+ JSON.stringify({
733
+ type: 'runtime:use:ok',
734
+ id: msg.id,
735
+ version: result.version,
736
+ runtimeDir: result.runtimeDir,
737
+ }),
738
+ )
739
+ } catch {}
740
+ return
741
+ }
742
+ if (msg.type === 'runtime:get') {
743
+ const active = this.getActiveRuntime()
744
+ try {
745
+ ws.send(
746
+ JSON.stringify({
747
+ type: 'runtime:get:ok',
748
+ id: msg.id,
749
+ active: active.version,
750
+ activeRuntimeDir: active.runtimeDir,
751
+ }),
752
+ )
753
+ } catch {}
754
+ return
755
+ }
756
+
757
+ // Browsers always send an Origin header, but the IDE needs a second,
758
+ // short-lived CLI-role socket to route typed app_* calls to an
759
+ // Contrast-dev-owned external runtime driver. `bridge:hello` is
760
+ // already the CLI handshake, so let it correct the provisional
761
+ // Origin-based role before this socket has registered a page. This reuses the existing
762
+ // daemon transport and call envelope; it does not add a second routing
763
+ // surface or let a registered sim switch roles mid-connection.
764
+ if (role === 'sim' && msg.type === 'bridge:hello' && sim && !sim.url) {
765
+ const provisionalId = sim.id
766
+ this.sims.delete(provisionalId)
767
+ if (this.primarySimId === provisionalId) {
768
+ this.primarySimId = this.getOpenSim()?.id ?? null
769
+ }
770
+ sim = null
771
+ role = 'cli'
772
+ this.cliIdentityKeyBySocket.set(ws, `ws-${this.nextCliFallbackId++}`)
773
+ this.writeConnectedRuntimeSnapshot()
774
+ this.broadcastSimAssignments()
775
+ this.broadcastSimClientStates()
776
+ }
777
+
778
+ if (role === 'sim') {
779
+ if (sim) {
780
+ sim.lastSeenAt = Date.now()
781
+ }
782
+ if (msg.type === 'bridge:register' && sim) {
783
+ const registration = msg as BridgeSimRegistrationMessage
784
+ const restored = this.tryRestoreSimId(sim, registration.simId)
785
+ sim.url = registration.url
786
+ sim.title = registration.title
787
+ sim.userAgent = registration.userAgent
788
+ this.writeConnectedRuntimeSnapshot()
789
+ // kind/meta are optional and free-form. only update if the
790
+ // sender supplied a value so we don't accidentally clear
791
+ // state on a heartbeat-style re-register.
792
+ if (typeof registration.kind === 'string' && registration.kind.trim()) {
793
+ sim.kind = registration.kind.trim()
794
+ }
795
+ if (registration.meta && typeof registration.meta === 'object') {
796
+ sim.meta = registration.meta as Record<string, unknown>
797
+ }
798
+ // re-evaluate primary now that the sim has a real page. promotion
799
+ // is otherwise decided once at bare-connect, before `url` is
800
+ // known — so a page-less sim that grabbed primary as the
801
+ // last-resort fallback (or a stale zombie still holding it)
802
+ // would never be superseded by this freshly-registered, actually
803
+ // driveable sim (QA F19-2). only adopt a *different* sim so a
804
+ // routine heartbeat re-register doesn't churn the assignment.
805
+ const reElected = this.primarySimId !== sim.id && this.shouldPromoteSim(sim)
806
+ if (reElected) this.primarySimId = sim.id
807
+ if (restored || reElected) {
808
+ this.broadcastSimAssignments()
809
+ this.broadcastSimClientStates()
810
+ }
811
+ return
812
+ }
813
+
814
+ if (msg.type === 'bridge:user-focus-state' && sim) {
815
+ const focusState = msg as BridgeSimUserFocusStateMessage
816
+ this.updateUserFocusLease(sim, focusState)
817
+ return
818
+ }
819
+
820
+ if (msg.type === 'bridge:user-interact' && sim) {
821
+ this.updateUserActivity(sim)
822
+ return
823
+ }
824
+
825
+ // write a partial patch into ~/.rnx/config.json. fired by the
826
+ // engine's settingsStore when a persisted key flips in a browser
827
+ // tab (which has no fs access of its own). after the merge, we
828
+ // broadcast the new full snapshot to every connected sim so any
829
+ // tab that has the engine config global picks up the change live
830
+ // — same shape as electron's `config:changed` IPC.
831
+ if (msg.type === 'bridge:write-shared-config') {
832
+ if (!msg.patch || typeof msg.patch !== 'object' || Array.isArray(msg.patch)) {
833
+ return
834
+ }
835
+ const patch = Object.fromEntries(
836
+ Object.entries(msg.patch),
837
+ ) satisfies Partial<SharedConfig>
838
+ try {
839
+ this.writeAndBroadcastSharedConfig(patch)
840
+ } catch (err) {
841
+ process.stderr.write(
842
+ `sootsim: bridge:write-shared-config failed: ${err instanceof Error ? err.message : String(err)}\n`,
843
+ )
844
+ return
845
+ }
846
+ return
847
+ }
848
+
849
+ // open a source file in the user's editor. triggered by tappable
850
+ // stack frames in sootsim's RedBox overlay.
851
+ if (msg.type === 'bridge:open-path') {
852
+ const filePath = typeof msg.path === 'string' ? msg.path : ''
853
+ const line =
854
+ typeof msg.line === 'number' && Number.isFinite(msg.line)
855
+ ? msg.line
856
+ : undefined
857
+ const column =
858
+ typeof msg.column === 'number' && Number.isFinite(msg.column)
859
+ ? msg.column
860
+ : undefined
861
+ if (filePath) {
862
+ void this.openPathInEditor(filePath, line, column)
863
+ }
864
+ return
865
+ }
866
+
867
+ // boot-clients: disconnect all CLI clients attached to this sim
868
+ // and hand the sim to the user. an explicit Boot is a strong claim,
869
+ // so we install a user-active lease instead of clearing — otherwise
870
+ // a reconnecting agent (most spawn a fresh socket within ms of close)
871
+ // claims the empty slot before the user can interact and the boot
872
+ // becomes a no-op the user has to repeat indefinitely.
873
+ if (msg.type === 'bridge:boot-clients' && sim) {
874
+ const booted: WebSocket[] = []
875
+ for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
876
+ if (attachedSimId === sim.id) {
877
+ booted.push(cliWs)
878
+ }
879
+ }
880
+ for (const cliWs of booted) {
881
+ this.cliSimBySocket.delete(cliWs)
882
+ try {
883
+ cliWs.close(1000, 'booted by sim')
884
+ } catch {}
885
+ }
886
+ const hadLease = !!sim.cliLease
887
+ sim.cliLease = {
888
+ kind: 'user-active',
889
+ cliIdentityKey: '__user-active__',
890
+ cliLabel: 'active user',
891
+ expiresAt: Date.now() + SootSimBridgeHost.USER_BOOT_LEASE_TTL_MS,
892
+ }
893
+ process.stderr.write(
894
+ `rnx booted ${booted.length} cli client(s)${hadLease ? ' (overrode prior lease)' : ''}; held sim for user [${sim.id}]\n`,
895
+ )
896
+ this.recordSimAction(sim.id, 'sim booted cli clients')
897
+ this.broadcastSimClientStates()
898
+ return
899
+ }
900
+
901
+ const internalPending = this.pendingCommands.get(msg.id)
902
+ if (internalPending) {
903
+ this.pendingCommands.delete(msg.id)
904
+ if (msg.error) internalPending.reject(new Error(msg.error))
905
+ else internalPending.resolve(msg.result)
906
+ return
907
+ }
908
+
909
+ const entry = this.cliBySentId.get(msg.id)
910
+ if (entry) {
911
+ this.cliBySentId.delete(msg.id)
912
+ if (entry.ws.readyState === WebSocket.OPEN) {
913
+ // include other CLI count so the client can warn about contention
914
+ const otherCliCount = this.getOtherCliIdentityCount(entry.ws, entry.simId)
915
+ const response =
916
+ otherCliCount > 0
917
+ ? { ...msg, id: entry.originalId, _otherCliCount: otherCliCount }
918
+ : { ...msg, id: entry.originalId }
919
+ entry.ws.send(JSON.stringify(response))
920
+ }
921
+ }
922
+ return
923
+ }
924
+
925
+ void (async () => {
926
+ this.cliLastCommandAt.set(ws, Date.now())
927
+ try {
928
+ if (msg.type === 'bridge:bye') {
929
+ // explicit goodbye from a cli about to exit. drop its socket
930
+ // state immediately so the next invocation from the same agent
931
+ // doesn't see this one as a phantom peer. the subsequent tcp
932
+ // close event becomes a no-op.
933
+ const hadSim = this.cliSimBySocket.delete(ws)
934
+ this.cliLastCommandAt.delete(ws)
935
+ this.cliIdentityKeyBySocket.delete(ws)
936
+ this.cliLabelBySocket.delete(ws)
937
+ for (const [sentId, entry] of this.cliBySentId) {
938
+ if (entry.ws === ws) this.cliBySentId.delete(sentId)
939
+ }
940
+ if (hadSim) this.broadcastSimClientStates()
941
+ return
942
+ }
943
+
944
+ if (msg.type === 'bridge:hello') {
945
+ const key =
946
+ typeof msg.cliIdentityKey === 'string' && msg.cliIdentityKey.trim()
947
+ ? msg.cliIdentityKey.trim()
948
+ : this.cliIdentityKeyBySocket.get(ws) ||
949
+ `ws-${this.nextCliFallbackId++}`
950
+ this.cliIdentityKeyBySocket.set(ws, key)
951
+ if (typeof msg.cliLabel === 'string' && msg.cliLabel.trim()) {
952
+ this.cliLabelBySocket.set(ws, msg.cliLabel.trim())
953
+ }
954
+ // same-identity cli sockets are allowed to coexist. this avoids
955
+ // self-disconnects when agent tooling issues multiple commands
956
+ // in parallel from the same logical identity key.
957
+ if (ws.readyState === WebSocket.OPEN) {
958
+ ws.send(
959
+ JSON.stringify({
960
+ id: msg.id,
961
+ result: {
962
+ cliIdentityKey: key,
963
+ leaseTtlMs: SootSimBridgeHost.CLI_LEASE_TTL_MS,
964
+ leasing: true,
965
+ },
966
+ }),
967
+ )
968
+ }
969
+ return
970
+ }
971
+
972
+ if (msg.type === 'bridge:list-sims') {
973
+ if (ws.readyState === WebSocket.OPEN) {
974
+ ws.send(
975
+ JSON.stringify({
976
+ id: msg.id,
977
+ result: this.listSims(this.cliIdentityKeyBySocket.get(ws)),
978
+ }),
979
+ )
980
+ }
981
+ return
982
+ }
983
+
984
+ if (msg.type === 'bridge:claim') {
985
+ const targetSim = await this.waitForSim(msg.simId)
986
+ const outcome = this.tryAcquireLease(ws, targetSim, {
987
+ force: msg.force === true,
988
+ })
989
+ if (!outcome.granted) {
990
+ if (ws.readyState === WebSocket.OPEN) {
991
+ ws.send(
992
+ JSON.stringify({
993
+ id: msg.id,
994
+ error: `sim ${targetSim.id} is locked by another cli`,
995
+ _locked: outcome.lock,
996
+ }),
997
+ )
998
+ }
999
+ return
1000
+ }
1001
+ this.setCliSimTarget(ws, targetSim.id)
1002
+ this.recordSimAction(
1003
+ targetSim.id,
1004
+ outcome.bootedCount > 0
1005
+ ? `cli force-claimed sim (booted ${outcome.bootedCount})`
1006
+ : 'cli claimed sim',
1007
+ )
1008
+ if (ws.readyState === WebSocket.OPEN) {
1009
+ ws.send(
1010
+ JSON.stringify({
1011
+ id: msg.id,
1012
+ result: {
1013
+ simId: targetSim.id,
1014
+ lockedBy: outcome.lease.cliIdentityKey,
1015
+ lockExpiresAt: outcome.lease.expiresAt,
1016
+ bootedCount: outcome.bootedCount,
1017
+ },
1018
+ }),
1019
+ )
1020
+ }
1021
+ return
1022
+ }
1023
+
1024
+ const targetSim = await this.waitForSim(msg.simId)
1025
+ if (shouldAcquireLease(msg)) {
1026
+ const outcome = this.tryAcquireLease(ws, targetSim)
1027
+ if (!outcome.granted) {
1028
+ if (ws.readyState === WebSocket.OPEN) {
1029
+ ws.send(
1030
+ JSON.stringify({
1031
+ id: msg.id,
1032
+ error: `sim ${targetSim.id} is locked by another cli — use \`rnx claim ${targetSim.id} --force\` or \`rnx open --new\``,
1033
+ _locked: outcome.lock,
1034
+ }),
1035
+ )
1036
+ }
1037
+ return
1038
+ }
1039
+ } else {
1040
+ // read-only / observational pass-through: still register this
1041
+ // cli as attached so list/describe shows it, but never block.
1042
+ this.ensureCliIdentityKey(ws)
1043
+ }
1044
+ this.setCliSimTarget(ws, targetSim.id)
1045
+ this.recordSimAction(targetSim.id, this.describeForwardedCommand(msg))
1046
+ const sentId = this.nextCommandId++
1047
+ this.cliBySentId.set(sentId, {
1048
+ simId: targetSim.id,
1049
+ ws,
1050
+ originalId: msg.id,
1051
+ })
1052
+ const { simId: _simId, ...forwarded } = msg
1053
+ targetSim.ws.send(JSON.stringify({ ...forwarded, id: sentId }))
1054
+ // `close` must never hang on the sim page. a frozen sim never
1055
+ // processes the forwarded close and never replies, so the CLI
1056
+ // command would otherwise time out after the full command
1057
+ // window (M3). treat close as fire-and-forget: ack the CLI now,
1058
+ // drop the pending relay entry so a late sim reply is harmless,
1059
+ // and disconnect the sim socket with the terminal close code if
1060
+ // the page has not closed itself within the grace window.
1061
+ if (forwarded.type === 'close') {
1062
+ this.cliBySentId.delete(sentId)
1063
+ if (ws.readyState === WebSocket.OPEN) {
1064
+ ws.send(
1065
+ JSON.stringify({
1066
+ id: msg.id,
1067
+ result: { requested: true, simId: targetSim.id },
1068
+ }),
1069
+ )
1070
+ }
1071
+ const simWs = targetSim.ws
1072
+ const closeTimer = setTimeout(() => {
1073
+ this.closeSimSocketFromHost(simWs)
1074
+ }, FORCE_CLOSE_GRACE_MS)
1075
+ unrefTimer(closeTimer)
1076
+ }
1077
+ } catch (err) {
1078
+ if (ws.readyState === WebSocket.OPEN) {
1079
+ ws.send(
1080
+ JSON.stringify({
1081
+ id: msg.id,
1082
+ error: err instanceof Error ? err.message : String(err),
1083
+ }),
1084
+ )
1085
+ }
1086
+ }
1087
+ })()
1088
+ })
1089
+
1090
+ ws.on('close', () => {
1091
+ // always drop agent subscriptions first — the FIFO refcount needs
1092
+ // to settle before any later broadcast fan-out fires.
1093
+ this.agentHost.unregisterSocket(ws)
1094
+ this.planeHost.unregisterSocket(ws)
1095
+ if (role === 'sim' && sim) {
1096
+ this.rememberDisconnectedSim(sim)
1097
+ if (this.primarySimId === sim.id) {
1098
+ this.primarySimId = this.getOpenSim()?.id ?? null
1099
+ }
1100
+ for (const [id, pending] of this.pendingCommands) {
1101
+ if (pending.simId !== sim.id) continue
1102
+ pending.reject(new Error('sim disconnected'))
1103
+ this.pendingCommands.delete(id)
1104
+ }
1105
+ for (const [sentId, entry] of this.cliBySentId) {
1106
+ if (entry.simId !== sim.id) continue
1107
+ if (entry.ws.readyState === WebSocket.OPEN) {
1108
+ entry.ws.send(
1109
+ JSON.stringify({
1110
+ id: entry.originalId,
1111
+ error: 'sim disconnected before responding',
1112
+ }),
1113
+ )
1114
+ }
1115
+ this.cliBySentId.delete(sentId)
1116
+ }
1117
+ this.broadcastSimAssignments()
1118
+ this.broadcastSimClientStates()
1119
+ } else if (role === 'cli') {
1120
+ const detached = this.cliSimBySocket.delete(ws)
1121
+ this.cliLastCommandAt.delete(ws)
1122
+ this.cliIdentityKeyBySocket.delete(ws)
1123
+ this.cliLabelBySocket.delete(ws)
1124
+ for (const [sentId, entry] of this.cliBySentId) {
1125
+ if (entry.ws === ws) this.cliBySentId.delete(sentId)
1126
+ }
1127
+ if (detached) {
1128
+ this.broadcastSimClientStates()
1129
+ }
1130
+ }
1131
+ })
1132
+ })
1133
+ }
1134
+
1135
+ /** after a successful bind: start the cli idle sweep, write the daemon/dev
1136
+ * lockfile (if this host owns it), seed the agent host, kick off the
1137
+ * heartbeat loop. idempotent across rebinds because close() tears down
1138
+ * every timer and the lockfile. */
1139
+ private afterBind() {
1140
+ process.stderr.write(`ws bridge listening on port ${this.port}\n`)
1141
+
1142
+ this.cliIdleTimer = setInterval(
1143
+ () => this.sweepIdleCliClients(),
1144
+ 30_000,
1145
+ ) as unknown as NodeJS.Timeout
1146
+ this.cliIdleTimer.unref()
1147
+
1148
+ this.wsHeartbeatTimer = setInterval(
1149
+ () => this.sweepDeadWebSockets(),
1150
+ SootSimBridgeHost.WS_HEARTBEAT_INTERVAL_MS,
1151
+ ) as unknown as NodeJS.Timeout
1152
+ this.wsHeartbeatTimer.unref()
1153
+
1154
+ if (this.shouldWriteLockfile) {
1155
+ try {
1156
+ ensureRnxHome()
1157
+ // atomic claim: bail if another fresh daemon's lockfile already
1158
+ // exists (stale ones are overwritten). last line of defense
1159
+ // against two daemons clobbering the same file between the
1160
+ // freshness check in serve.ts and here.
1161
+ const claimed = claimDaemonLockfile(this.buildLockfileSnapshot())
1162
+ if (!claimed) {
1163
+ throw new Error(
1164
+ 'another rnx daemon wrote the lockfile during startup — aborting',
1165
+ )
1166
+ }
1167
+ } catch (err) {
1168
+ process.stderr.write(
1169
+ `ws bridge failed to claim daemon lockfile: ${String(err)}\n`,
1170
+ )
1171
+ throw err
1172
+ }
1173
+ this.heartbeatTimer = setInterval(() => {
1174
+ try {
1175
+ this.writeLockfileSnapshot()
1176
+ } catch {}
1177
+ }, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
1178
+ this.heartbeatTimer.unref()
1179
+ this.startRuntimeUpdater()
1180
+ }
1181
+
1182
+ if (this.shouldWriteDevLockfile) {
1183
+ try {
1184
+ this.writeDevLockfileSnapshot()
1185
+ } catch (err) {
1186
+ process.stderr.write(
1187
+ `ws bridge failed to write dev bridge lockfile: ${String(err)}\n`,
1188
+ )
1189
+ }
1190
+ this.devHeartbeatTimer = setInterval(() => {
1191
+ try {
1192
+ this.writeDevLockfileSnapshot()
1193
+ } catch {}
1194
+ }, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
1195
+ this.devHeartbeatTimer.unref()
1196
+ }
1197
+
1198
+ // seed the attached-projects store from the demo registry on first
1199
+ // daemon boot. idempotent; no-ops once the store has anything in it.
1200
+ void this.agentHost.seedOnBoot()
1201
+ }
1202
+
1203
+ private bootstrapping = true
1204
+
1205
+ private connectedRuntimeVersions(): string[] {
1206
+ const versions = new Set<string>()
1207
+ for (const sim of this.sims.values()) {
1208
+ try {
1209
+ const url = new URL(sim.url || sim.origin || 'http://localhost')
1210
+ const version = runtimeVersionFromHostname(url.hostname)
1211
+ if (version) versions.add(version)
1212
+ } catch {}
1213
+ }
1214
+ return [...versions].sort()
1215
+ }
1216
+
1217
+ private buildLockfileSnapshot(): DaemonLockfile {
1218
+ return {
1219
+ schema: 1,
1220
+ pid: process.pid,
1221
+ platform: process.platform,
1222
+ bridgePort: this.effectivePort,
1223
+ runtimePort: this.effectivePort,
1224
+ activeRuntime: this.activeRuntimeVersion,
1225
+ activeRuntimeDir: this.activeRuntimeDirPath,
1226
+ servedRuntimes: this.connectedRuntimeVersions(),
1227
+ startedAt: this.startedAt,
1228
+ heartbeatAt: Date.now(),
1229
+ bootstrapping: this.bootstrapping,
1230
+ }
1231
+ }
1232
+
1233
+ private buildDevLockfileSnapshot(): DevBridgeLockfile {
1234
+ const shellPort = this.getShellPort?.() ?? null
1235
+ return {
1236
+ schema: 1,
1237
+ pid: process.pid,
1238
+ platform: process.platform,
1239
+ bridgePort: this.effectivePort,
1240
+ runtimePort: this.effectivePort,
1241
+ ...(shellPort && shellPort > 0 ? { shellPort } : {}),
1242
+ cwd: process.cwd(),
1243
+ startedAt: this.startedAt,
1244
+ heartbeatAt: Date.now(),
1245
+ source: 'vite-dev',
1246
+ servedRuntimes: this.connectedRuntimeVersions(),
1247
+ }
1248
+ }
1249
+
1250
+ private writeLockfileSnapshot() {
1251
+ writeDaemonLockfile(this.buildLockfileSnapshot())
1252
+ }
1253
+
1254
+ private writeDevLockfileSnapshot() {
1255
+ writeDevBridgeLockfile(this.buildDevLockfileSnapshot())
1256
+ }
1257
+
1258
+ private writeConnectedRuntimeSnapshot() {
1259
+ if (this.shouldWriteLockfile && this.httpServer) {
1260
+ try {
1261
+ this.writeLockfileSnapshot()
1262
+ } catch {}
1263
+ }
1264
+ if (this.shouldWriteDevLockfile && this.httpServer) {
1265
+ try {
1266
+ this.writeDevLockfileSnapshot()
1267
+ } catch {}
1268
+ }
1269
+ }
1270
+
1271
+ private refreshActiveRuntime() {
1272
+ this.activeRuntimeVersion = readActiveRuntime()
1273
+ // mode boundary (see runtime-assets resolveDevCheckoutRuntimeRoot): inside
1274
+ // the Contrast monorepo dev checkout serve the fresh dev-stack build so local
1275
+ // engine edits reach previews without a publish; otherwise the installed
1276
+ // runtime. the version above stays the installed version for display.
1277
+ this.activeRuntimeDirPath = resolveDevCheckoutRuntimeRoot() ?? getActiveRuntimeDir()
1278
+ }
1279
+
1280
+ private runServerScan(): Promise<DiscoveredServer[]> {
1281
+ if (this.inflightScan) return this.inflightScan
1282
+ const excludePorts = this.effectivePort > 0 ? [this.effectivePort] : []
1283
+ this.inflightScan = scanDevServers({
1284
+ excludePorts,
1285
+ buildIconProxyUrl: buildBrowserAssetProxyUrl,
1286
+ })
1287
+ .then((results) => {
1288
+ this.scanCache = results
1289
+ this.scanCacheAt = Date.now()
1290
+ return results
1291
+ })
1292
+ .catch((err: unknown) => {
1293
+ const message = err instanceof Error ? err.message : String(err)
1294
+ console.error('[rnx] /__server-scan failed:', message)
1295
+ return this.scanCache ?? []
1296
+ })
1297
+ .finally(() => {
1298
+ this.inflightScan = null
1299
+ })
1300
+ return this.inflightScan
1301
+ }
1302
+
1303
+ // serves one staged camera fixture. addressed by basename inside
1304
+ // ~/.rnx/camera-fixtures, never by a caller-supplied path, so there is
1305
+ // no traversal surface to defend: a name that is not a plain basename is
1306
+ // rejected outright. range support is not optional here — chrome asks for
1307
+ // byte ranges on media and cannot seek or loop a fixture without it.
1308
+ private handleCameraFixture(name: string, req: IncomingMessage, res: ServerResponse) {
1309
+ const method = (req.method || 'GET').toUpperCase()
1310
+ const cors = {
1311
+ 'Access-Control-Allow-Origin': '*',
1312
+ 'Cache-Control': 'no-store',
1313
+ }
1314
+ if (method === 'OPTIONS') {
1315
+ res.writeHead(204, {
1316
+ ...cors,
1317
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
1318
+ 'Access-Control-Allow-Headers': 'range',
1319
+ })
1320
+ res.end()
1321
+ return
1322
+ }
1323
+ if (method !== 'GET' && method !== 'HEAD') {
1324
+ res.writeHead(405, cors)
1325
+ res.end()
1326
+ return
1327
+ }
1328
+ if (!CAMERA_FIXTURE_NAME_RE.test(name)) {
1329
+ res.writeHead(400, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
1330
+ res.end('bad fixture name')
1331
+ return
1332
+ }
1333
+
1334
+ const root = cameraFixturesDir()
1335
+ const filePath = path.join(root, name)
1336
+ let stats: fs.Stats
1337
+ try {
1338
+ // a symlink dropped into the fixtures dir is the one way out of it.
1339
+ // lstat rather than stat so a link is refused instead of followed.
1340
+ stats = fs.lstatSync(filePath)
1341
+ } catch {
1342
+ res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
1343
+ res.end('not found')
1344
+ return
1345
+ }
1346
+ if (!stats.isFile()) {
1347
+ res.writeHead(403, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
1348
+ res.end('forbidden')
1349
+ return
1350
+ }
1351
+
1352
+ const contentType =
1353
+ HTTP_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
1354
+ const total = stats.size
1355
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '')
1356
+ if (range) {
1357
+ const [, startRaw, endRaw] = range
1358
+ const start = startRaw ? Number(startRaw) : 0
1359
+ const end = endRaw ? Math.min(Number(endRaw), total - 1) : total - 1
1360
+ if (!Number.isFinite(start) || start > end || start >= total) {
1361
+ res.writeHead(416, { ...cors, 'Content-Range': `bytes */${total}` })
1362
+ res.end()
1363
+ return
1364
+ }
1365
+ res.writeHead(206, {
1366
+ ...cors,
1367
+ 'Content-Type': contentType,
1368
+ 'Content-Range': `bytes ${start}-${end}/${total}`,
1369
+ 'Accept-Ranges': 'bytes',
1370
+ 'Content-Length': String(end - start + 1),
1371
+ })
1372
+ if (method === 'HEAD') {
1373
+ res.end()
1374
+ return
1375
+ }
1376
+ fs.createReadStream(filePath, { start, end }).pipe(res)
1377
+ return
1378
+ }
1379
+
1380
+ res.writeHead(200, {
1381
+ ...cors,
1382
+ 'Content-Type': contentType,
1383
+ 'Accept-Ranges': 'bytes',
1384
+ 'Content-Length': String(total),
1385
+ })
1386
+ if (method === 'HEAD') {
1387
+ res.end()
1388
+ return
1389
+ }
1390
+ fs.createReadStream(filePath).pipe(res)
1391
+ }
1392
+
1393
+ private handleServerScan(res: ServerResponse) {
1394
+ const sendJson = (body: DiscoveredServer[]) => {
1395
+ res.writeHead(200, {
1396
+ 'Content-Type': 'application/json; charset=utf-8',
1397
+ 'Cache-Control': 'no-store',
1398
+ })
1399
+ res.end(JSON.stringify(body))
1400
+ }
1401
+ const age = Date.now() - this.scanCacheAt
1402
+ if (this.scanCache && age < SootSimBridgeHost.SCAN_FRESH_MS) {
1403
+ sendJson(this.scanCache)
1404
+ return
1405
+ }
1406
+ if (this.scanCache) {
1407
+ // stale-while-revalidate: return last known good immediately, kick a
1408
+ // fresh scan in the background.
1409
+ sendJson(this.scanCache)
1410
+ void this.runServerScan().catch(() => {})
1411
+ return
1412
+ }
1413
+ void this.runServerScan().then((results) => sendJson(results))
1414
+ }
1415
+
1416
+ private resolveRuntimeUpdateIntervalMs() {
1417
+ const raw = Number(process.env[RUNTIME_UPDATE_INTERVAL_ENV])
1418
+ if (Number.isFinite(raw) && raw > 0) return Math.max(100, Math.round(raw))
1419
+ return DEFAULT_RUNTIME_UPDATE_INTERVAL_MS
1420
+ }
1421
+
1422
+ private startRuntimeUpdater() {
1423
+ if (!this.shouldWriteLockfile || this.runtimeUpdateTimer) return
1424
+ if (fs.existsSync(path.join(rnxHomeDir(), 'runtime-update-disabled'))) {
1425
+ this.bootstrapping = false
1426
+ if (this.httpServer) this.writeLockfileSnapshot()
1427
+ return
1428
+ }
1429
+ // an isolated dev daemon (explicit RNX_HOME, dev checkout, nothing
1430
+ // staged) serves the repo's public/sootsim build. auto-installing the
1431
+ // published runtime here would stage it active and shadow that dev build
1432
+ // (runtime-assets prefers a staged runtime under an explicit home), so the
1433
+ // daemon would silently validate published code instead of the checkout.
1434
+ // local-publish writes the marker above when it stages a source build.
1435
+ if (process.env.RNX_HOME && isSootsimDevCheckout() && !readActiveRuntime()) {
1436
+ return
1437
+ }
1438
+ void this.runRuntimeUpdate('startup')
1439
+ const intervalMs = this.resolveRuntimeUpdateIntervalMs()
1440
+ this.runtimeUpdateTimer = setInterval(() => {
1441
+ void this.runRuntimeUpdate('periodic')
1442
+ }, intervalMs) as unknown as NodeJS.Timeout
1443
+ this.runtimeUpdateTimer.unref()
1444
+ }
1445
+
1446
+ private runRuntimeUpdate(reason: 'startup' | 'periodic'): Promise<void> {
1447
+ if (this.runtimeUpdateInFlight) return this.runtimeUpdateInFlight
1448
+ this.runtimeUpdateInFlight = (async () => {
1449
+ try {
1450
+ if (reason === 'startup') {
1451
+ process.stderr.write('rnx: checking for runtime updates…\n')
1452
+ }
1453
+ const result = await rnxRuntime.updateToLatest({
1454
+ protectVersions: this.connectedRuntimeVersions(),
1455
+ })
1456
+ if (!result.updated || !result.latestVersion) {
1457
+ if (reason === 'startup') {
1458
+ process.stderr.write(
1459
+ `rnx: runtime ${this.activeRuntimeVersion ?? '(none)'} is current\n`,
1460
+ )
1461
+ }
1462
+ return
1463
+ }
1464
+ const previousVersion = this.activeRuntimeVersion
1465
+ const active = this.setActiveRuntime(result.latestVersion)
1466
+ process.stderr.write(`rnx runtime updated to ${active.version} (${reason})\n`)
1467
+ // the daemon's stderr goes to a log nobody watches — drop a one-shot
1468
+ // notice so the next interactive CLI run tells the user their engine
1469
+ // moved (consumed + deleted by bin.ts).
1470
+ try {
1471
+ writeRuntimeUpgradeNotice({
1472
+ from: previousVersion,
1473
+ to: active.version,
1474
+ at: Date.now(),
1475
+ })
1476
+ } catch {}
1477
+ } catch (err) {
1478
+ process.stderr.write(
1479
+ `rnx runtime update failed (${reason}): ${
1480
+ err instanceof Error ? err.message : String(err)
1481
+ }\n`,
1482
+ )
1483
+ } finally {
1484
+ this.runtimeUpdateInFlight = null
1485
+ // first-boot gate flips off once the startup pass finishes (success
1486
+ // or fail) — clients can navigate even if the network update failed
1487
+ // as long as some runtime is on disk. without this the splash would
1488
+ // wait forever on a temporary network blip.
1489
+ if (reason === 'startup' && this.bootstrapping) {
1490
+ this.bootstrapping = false
1491
+ if (this.shouldWriteLockfile && this.httpServer) {
1492
+ try {
1493
+ this.writeLockfileSnapshot()
1494
+ } catch {}
1495
+ }
1496
+ process.stderr.write('rnx: ready\n')
1497
+ }
1498
+ }
1499
+ })()
1500
+ return this.runtimeUpdateInFlight
1501
+ }
1502
+
1503
+ /** update the active runtime on disk + in memory. the caller guarantees
1504
+ * the version directory exists. pushes a runtime:changed message to all
1505
+ * connected sims so electron (or any renderer) can reload. */
1506
+ setActiveRuntime(version: string): { version: string; runtimeDir: string | null } {
1507
+ writeActiveRuntime(version)
1508
+ this.refreshActiveRuntime()
1509
+ if (this.shouldWriteLockfile && this.httpServer) {
1510
+ try {
1511
+ this.writeLockfileSnapshot()
1512
+ } catch {}
1513
+ }
1514
+ // broadcast to sims so electron can reload its webContents without
1515
+ // a manual restart. CLI clients ignore this message.
1516
+ const payload = JSON.stringify({
1517
+ type: 'runtime:changed',
1518
+ version,
1519
+ runtimeDir: this.activeRuntimeDirPath,
1520
+ })
1521
+ for (const sim of this.sims.values()) {
1522
+ try {
1523
+ const simUrl = new URL(sim.url || sim.origin || 'http://localhost')
1524
+ if (runtimeVersionFromHostname(simUrl.hostname)) continue
1525
+ } catch {}
1526
+ if (sim.ws.readyState === WebSocket.OPEN) {
1527
+ try {
1528
+ sim.ws.send(payload)
1529
+ } catch {}
1530
+ }
1531
+ }
1532
+ return { version, runtimeDir: this.activeRuntimeDirPath }
1533
+ }
1534
+
1535
+ getActiveRuntime(): { version: string | null; runtimeDir: string | null } {
1536
+ return {
1537
+ version: this.activeRuntimeVersion,
1538
+ runtimeDir: this.activeRuntimeDirPath,
1539
+ }
1540
+ }
1541
+
1542
+ /** last-ditch lockfile cleanup. safe to call from a synchronous
1543
+ * `process.on('exit', ...)` handler since it only does a fs.unlinkSync. */
1544
+ removeLockfile() {
1545
+ if (!this.shouldWriteLockfile) return
1546
+ try {
1547
+ removeDaemonLockfile({ pid: process.pid, startedAt: this.startedAt })
1548
+ } catch {}
1549
+ }
1550
+
1551
+ /** minimal HTTP request handler attached to the same node http server
1552
+ * that hosts the WS upgrade. handles:
1553
+ * GET /healthz json status for supervisors / curl
1554
+ * GET / + everything serves from the active runtime dist, SPA fallback
1555
+ * non-upgrade routes that don't match serve index.html (SPA behavior) so
1556
+ * electron's webContents can navigate freely inside the runtime. */
1557
+ private handleHttpRequest(req: IncomingMessage, res: ServerResponse) {
1558
+ // cross-origin isolation — without this, Electron / Chromium refuses
1559
+ // `SharedArrayBuffer`, and the engine's render-worker crashes on boot
1560
+ // ("render worker crashed during boot" / "app:one surface registration
1561
+ // failed"). SAB powers the shell-scene fast channel and the worklet
1562
+ // runtime's SharedValues. mirrors sootsim-shell/vite.config.ts (dev) and
1563
+ // contrast.dev app/_middleware.ts (prod) so the cli daemon serves the
1564
+ // same surface those two already enforce. set via setHeader so subsequent
1565
+ // writeHead() calls preserve them (writeHead only overrides keys it
1566
+ // explicitly passes). Safari/iOS WebKit does not isolate documents for
1567
+ // COEP=credentialless, so use require-corp and keep bridge-served/proxied
1568
+ // resources same-origin with CORP where needed. mirrors the SootSim shell
1569
+ // dev server and the /sootsim production override.
1570
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
1571
+ res.setHeader('Cross-Origin-Embedder-Policy', SOOTSIM_CROSS_ORIGIN_EMBEDDER_POLICY)
1572
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
1573
+ res.setHeader('Document-Policy', 'js-profiling')
1574
+
1575
+ // proxy + app-api routes need to accept POST/PUT/DELETE/PATCH/OPTIONS
1576
+ // because they forward the request to a real upstream. handle them
1577
+ // BEFORE the read-only method check below — otherwise tenant bundles
1578
+ // fetching cross-origin APIs through the daemon (e.g. when the shell
1579
+ // dev server isn't running) get a 405 instead of the proxied response,
1580
+ // and downstream NetInfo-style reachability probes flip to "offline".
1581
+ if (isFetchProxyRequestUrl(req.url)) {
1582
+ void handleFetchProxyRequest(req, res)
1583
+ return
1584
+ }
1585
+ if (isAppApiRequestUrl(req.url) && handleAppApiRequest(req, res)) {
1586
+ return
1587
+ }
1588
+
1589
+ if (isReplacementModuleRequestUrl(req.url)) {
1590
+ void handleReplacementModuleRequest(req, res)
1591
+ return
1592
+ }
1593
+
1594
+ const method = (req.method || 'GET').toUpperCase()
1595
+ let url: URL
1596
+ try {
1597
+ url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
1598
+ } catch {
1599
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
1600
+ res.end('invalid request URL or host')
1601
+ return
1602
+ }
1603
+
1604
+ // /__sootsim/shared-config is the one awaited HTTP write. ordinary
1605
+ // settings fan out over WS, but destructive reloads need confirmation
1606
+ // that the disk source of truth accepted their final reset first.
1607
+ if (url.pathname === '/__sootsim/shared-config') {
1608
+ res.setHeader('Access-Control-Allow-Origin', '*')
1609
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
1610
+ res.setHeader('Cache-Control', 'no-store')
1611
+ if (method === 'OPTIONS') {
1612
+ res.writeHead(204, { Allow: 'GET, HEAD, POST, OPTIONS' })
1613
+ res.end()
1614
+ return
1615
+ }
1616
+ if (method === 'GET' || method === 'HEAD') {
1617
+ let body = '{}'
1618
+ try {
1619
+ body = JSON.stringify(readSharedConfig())
1620
+ } catch {}
1621
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1622
+ if (method === 'HEAD') res.end()
1623
+ else res.end(body)
1624
+ return
1625
+ }
1626
+ if (method === 'POST') {
1627
+ void (async () => {
1628
+ try {
1629
+ const chunks: Buffer[] = []
1630
+ let size = 0
1631
+ for await (const chunk of req) {
1632
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1633
+ size += buffer.byteLength
1634
+ if (size > 8 * 1024 * 1024) {
1635
+ res.writeHead(413, { 'Content-Type': 'text/plain; charset=utf-8' })
1636
+ res.end('shared config patch is too large')
1637
+ return
1638
+ }
1639
+ chunks.push(buffer)
1640
+ }
1641
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
1642
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1643
+ throw new Error('shared config patch must be an object')
1644
+ }
1645
+ const patch = Object.fromEntries(
1646
+ Object.entries(parsed),
1647
+ ) satisfies Partial<SharedConfig>
1648
+ const next = this.writeAndBroadcastSharedConfig(patch)
1649
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1650
+ res.end(JSON.stringify(next))
1651
+ } catch (error) {
1652
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
1653
+ res.end(
1654
+ `invalid shared config patch: ${error instanceof Error ? error.message : String(error)}`,
1655
+ )
1656
+ }
1657
+ })()
1658
+ return
1659
+ }
1660
+ res.writeHead(405, { Allow: 'GET, HEAD, POST, OPTIONS' })
1661
+ res.end('method not allowed')
1662
+ return
1663
+ }
1664
+
1665
+ // /__send-to-box — the rail button. the shell cannot create a cloud box
1666
+ // itself: the account credential lives on this machine, not in the page.
1667
+ // it sits above the read-only guard below because it is a POST.
1668
+ if (url.pathname === '/__send-to-box') {
1669
+ res.setHeader('Access-Control-Allow-Origin', '*')
1670
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
1671
+ res.setHeader('Cache-Control', 'no-store')
1672
+ if (method === 'OPTIONS') {
1673
+ res.writeHead(204, { Allow: 'POST, OPTIONS' })
1674
+ res.end()
1675
+ return
1676
+ }
1677
+ if (method !== 'POST') {
1678
+ res.writeHead(405, { Allow: 'POST, OPTIONS' })
1679
+ res.end('method not allowed')
1680
+ return
1681
+ }
1682
+ void (async () => {
1683
+ try {
1684
+ const chunks: Buffer[] = []
1685
+ for await (const chunk of req) {
1686
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
1687
+ }
1688
+ const text = Buffer.concat(chunks).toString('utf8').trim()
1689
+ const parsed: unknown = text ? JSON.parse(text) : {}
1690
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1691
+ throw new Error('send to box needs a json object body')
1692
+ }
1693
+ const body: Record<string, unknown> = { ...parsed }
1694
+ const { CHOOSE_ACCOUNT_HINT, resolveCliAuth, authHeaderValue } =
1695
+ await import('../../cli/auth.ts')
1696
+ const auth = resolveCliAuth()
1697
+ if (!auth) {
1698
+ res.writeHead(401, { 'Content-Type': 'application/json' })
1699
+ res.end(
1700
+ JSON.stringify({
1701
+ error: `send to box needs an account: run \`${rnxPublicBrand.commandName} login\` in this checkout`,
1702
+ }),
1703
+ )
1704
+ return
1705
+ }
1706
+ // the CLI would exit the process here; a daemon serving the rail
1707
+ // answers the button instead.
1708
+ const accountId = auth.kind === 'session' ? auth.accountId : null
1709
+ if (auth.kind === 'session' && accountId === null) {
1710
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1711
+ res.end(JSON.stringify({ error: CHOOSE_ACCOUNT_HINT }))
1712
+ return
1713
+ }
1714
+ const { sendToBox } = await import('../../cli/send-to-box.ts')
1715
+ const result = await sendToBox({
1716
+ send: (command) =>
1717
+ this.sendCommand({
1718
+ ...command,
1719
+ simId: typeof command.simId === 'string' ? command.simId : undefined,
1720
+ }),
1721
+ authorization: authHeaderValue(auth),
1722
+ accountId,
1723
+ carryAuth: body.carryAuth !== false,
1724
+ boxName: typeof body.boxName === 'string' ? body.boxName : null,
1725
+ simId: typeof body.simId === 'string' ? body.simId : undefined,
1726
+ })
1727
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1728
+ res.end(JSON.stringify(result))
1729
+ } catch (error) {
1730
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1731
+ res.end(
1732
+ JSON.stringify({
1733
+ error: error instanceof Error ? error.message : String(error),
1734
+ }),
1735
+ )
1736
+ }
1737
+ })()
1738
+ return
1739
+ }
1740
+
1741
+ // /__camera-fixtures/<name> — the staged video behind `rnx camera play`.
1742
+ // the shell page is a different origin from this daemon (vite dev on 5173,
1743
+ // daemon on 7668+), and the camera pipeline draws the <video> to a canvas
1744
+ // and reads it back, so this needs real CORS or the canvas taints and
1745
+ // createImageBitmap throws. it sits above the read-only guard below
1746
+ // because it answers its own OPTIONS preflight with the CORS headers that
1747
+ // guard's bare 405 would omit.
1748
+ if (url.pathname.startsWith(CAMERA_FIXTURE_ROUTE)) {
1749
+ this.handleCameraFixture(url.pathname.slice(CAMERA_FIXTURE_ROUTE.length), req, res)
1750
+ return
1751
+ }
1752
+
1753
+ // every other daemon route is read-only unless its owning proxy handled
1754
+ // the mutation above.
1755
+ if (method !== 'GET' && method !== 'HEAD') {
1756
+ res.writeHead(405, { Allow: 'GET, HEAD' })
1757
+ res.end('method not allowed')
1758
+ return
1759
+ }
1760
+
1761
+ // /__bundle-proxy?url=<encoded> — runtime/worker fetches metro bundles +
1762
+ // assets through here. needed because workers can't directly fetch a
1763
+ // cross-origin URL without CORS headers, and metro doesn't set them.
1764
+ // we proxy the request and stream the response back.
1765
+ if (url.pathname === '/__bundle-proxy') {
1766
+ void handleBundleProxyRequest(req, res)
1767
+ return
1768
+ }
1769
+
1770
+ // /__server-scan — list local metro/expo/vxrn/one dev servers. tenant
1771
+ // worker ConnectRN polls this every few seconds to populate the device
1772
+ // picker. mirrors packages/sootsim-shell/src/dev-middleware.ts so both
1773
+ // host environments (vite dev and the prod CLI daemon) return identical
1774
+ // JSON. icon URLs are rewritten through /__bundle-proxy so the browser
1775
+ // doesn't hit cross-origin loads against the discovered dev server.
1776
+ if (url.pathname === '/__server-scan') {
1777
+ this.handleServerScan(res)
1778
+ return
1779
+ }
1780
+
1781
+ if (url.pathname === '/healthz') {
1782
+ res.writeHead(200, {
1783
+ 'Content-Type': 'application/json',
1784
+ 'Cache-Control': 'no-store',
1785
+ })
1786
+ res.end(
1787
+ JSON.stringify({
1788
+ ok: true,
1789
+ pid: process.pid,
1790
+ platform: process.platform,
1791
+ bridgePort: this.effectivePort,
1792
+ runtimePort: this.effectivePort,
1793
+ activeRuntime: this.activeRuntimeVersion,
1794
+ bridgeSource: this.shouldWriteDevLockfile
1795
+ ? 'vite-dev'
1796
+ : this.shouldWriteLockfile
1797
+ ? 'daemon'
1798
+ : 'embedded',
1799
+ startedAt: this.startedAt,
1800
+ uptimeMs: this.startedAt > 0 ? Date.now() - this.startedAt : 0,
1801
+ }),
1802
+ )
1803
+ return
1804
+ }
1805
+
1806
+ // every installed-runtime shell opens on a version-owned localhost origin.
1807
+ // this keeps its root-relative chunks on one immutable tree while a
1808
+ // background update changes the active runtime for future shells.
1809
+ const versionedRuntimeRequest = isRuntimeVersionHostname(url.hostname)
1810
+ const requestedRuntime = runtimeVersionFromHostname(url.hostname)
1811
+ if (!versionedRuntimeRequest) this.refreshActiveRuntime()
1812
+ const baseDir = versionedRuntimeRequest
1813
+ ? resolveRuntimeRootForHostname(url.hostname)
1814
+ : this.activeRuntimeDirPath
1815
+ if (!baseDir) {
1816
+ res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
1817
+ res.end(
1818
+ versionedRuntimeRequest
1819
+ ? `rnx: runtime ${requestedRuntime ?? '(invalid version origin)'} is not installed. run \`rnx open\` from that app again.`
1820
+ : 'rnx: no active runtime installed. run `rnx runtime install` to fetch one.',
1821
+ )
1822
+ return
1823
+ }
1824
+
1825
+ // strip optional runtime prefixes so the daemon can serve both the
1826
+ // local entrypoint and the production-built /sootsim asset graph.
1827
+ let rel = url.pathname
1828
+ if (rel === '/runtime' || rel === '/runtime/') rel = '/'
1829
+ else if (rel.startsWith('/runtime/')) rel = rel.slice('/runtime'.length)
1830
+ else if (rel === '/sootsim' || rel === '/sootsim/') rel = '/'
1831
+ else if (rel.startsWith('/sootsim/')) rel = rel.slice('/sootsim'.length)
1832
+ if (rel === '' || rel === '/') rel = '/index.html'
1833
+
1834
+ // reject obviously-malicious paths up front:
1835
+ // NUL bytes (some fs APIs truncate, confusing downstream readers)
1836
+ // backslashes on non-Windows (treated literally in names, but
1837
+ // frequently used to sneak past string-level .. checks)
1838
+ // raw .. segments (path.resolve collapses them, but reject on sight
1839
+ // so traversal attempts surface as 400s in logs)
1840
+ if (rel.includes('\0')) {
1841
+ res.writeHead(400)
1842
+ res.end('bad request')
1843
+ return
1844
+ }
1845
+ if (process.platform !== 'win32' && rel.includes('\\')) {
1846
+ res.writeHead(400)
1847
+ res.end('bad request')
1848
+ return
1849
+ }
1850
+ for (const segment of rel.split('/')) {
1851
+ if (segment === '..') {
1852
+ res.writeHead(403)
1853
+ res.end('forbidden')
1854
+ return
1855
+ }
1856
+ }
1857
+
1858
+ const resolved = path.resolve(baseDir, '.' + rel)
1859
+ const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep
1860
+ if (!resolved.startsWith(baseWithSep) && resolved !== baseDir) {
1861
+ res.writeHead(403)
1862
+ res.end('forbidden')
1863
+ return
1864
+ }
1865
+
1866
+ // realpath the resolved path before serving. this catches symlinks
1867
+ // inside baseDir that point outside — without it, an attacker who can
1868
+ // drop a symlink in the runtime dir gets arbitrary-read through the
1869
+ // daemon. realpath also collapses any remaining normalizable segments.
1870
+ fs.realpath(resolved, (realErr, realResolved) => {
1871
+ const servePath = realErr ? resolved : realResolved
1872
+ const servePathWithSep = servePath.endsWith(path.sep)
1873
+ ? servePath
1874
+ : servePath + path.sep
1875
+ if (!realErr) {
1876
+ const realBaseWithSep = (() => {
1877
+ try {
1878
+ const rb = fs.realpathSync(baseDir)
1879
+ return rb.endsWith(path.sep) ? rb : rb + path.sep
1880
+ } catch {
1881
+ return baseWithSep
1882
+ }
1883
+ })()
1884
+ if (
1885
+ !servePathWithSep.startsWith(realBaseWithSep) &&
1886
+ servePath + path.sep !== realBaseWithSep
1887
+ ) {
1888
+ res.writeHead(403)
1889
+ res.end('forbidden')
1890
+ return
1891
+ }
1892
+ }
1893
+
1894
+ fs.stat(servePath, (err, stats) => {
1895
+ if (err || !stats?.isFile()) {
1896
+ // SPA fallback — but only for extensionless paths. a 404 on
1897
+ // /main.a1b2c3.js should 404, not return index.html as js,
1898
+ // which the sim then chokes on.
1899
+ const ext = path.extname(rel).toLowerCase()
1900
+ if (ext && ext !== '.html') {
1901
+ res.writeHead(404)
1902
+ res.end('not found')
1903
+ return
1904
+ }
1905
+ // never SPA-fallback internal endpoint namespaces — those are
1906
+ // expected to return JSON (or a real 404) and the engine calls
1907
+ // .json() on the response. without this, a tenant-worker fetch
1908
+ // to an unimplemented /__foo or /api/foo gets a 200 + index.html
1909
+ // and explodes with "Unexpected token '<', '<!doctype'... is not
1910
+ // valid JSON". /__server-scan is the canonical case (see the
1911
+ // handler above).
1912
+ if (rel.startsWith('/__') || rel.startsWith('/api/') || rel === '/api') {
1913
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
1914
+ res.end('not found')
1915
+ return
1916
+ }
1917
+ const indexPath = path.join(baseDir, 'index.html')
1918
+ fs.readFile(indexPath, (err2, data) => {
1919
+ if (err2) {
1920
+ res.writeHead(404)
1921
+ res.end('not found')
1922
+ return
1923
+ }
1924
+ res.writeHead(200, {
1925
+ 'Content-Type': 'text/html; charset=utf-8',
1926
+ 'Cache-Control': 'no-store',
1927
+ })
1928
+ if (method === 'HEAD') {
1929
+ res.end()
1930
+ return
1931
+ }
1932
+ res.end(
1933
+ injectSharedConfigIntoHtml(data, this.effectivePort, this.contrastOrigin),
1934
+ )
1935
+ })
1936
+ return
1937
+ }
1938
+ const ext = path.extname(servePath).toLowerCase()
1939
+ const contentType = HTTP_MIME_TYPES[ext] || 'application/octet-stream'
1940
+ // no-store across the board: the runtime is served from a versioned
1941
+ // directory but the HTTP path doesn't include the version, so the
1942
+ // same URL serves different content after a runtime swap. caching
1943
+ // any asset means a stale-bundle reload after runtime:changed.
1944
+ res.writeHead(200, {
1945
+ 'Content-Type': contentType,
1946
+ 'Cache-Control': 'no-store',
1947
+ })
1948
+ if (method === 'HEAD') {
1949
+ res.end()
1950
+ return
1951
+ }
1952
+ // html responses inline the daemon-injected `__sootsimSharedConfig`
1953
+ // global so the engine's settingsStore initializes from disk on
1954
+ // boot. for everything else stream the file straight through.
1955
+ if (ext === '.html') {
1956
+ fs.readFile(servePath, (readErr, data) => {
1957
+ if (readErr) {
1958
+ try {
1959
+ res.end()
1960
+ } catch {}
1961
+ return
1962
+ }
1963
+ res.end(
1964
+ injectSharedConfigIntoHtml(data, this.effectivePort, this.contrastOrigin),
1965
+ )
1966
+ })
1967
+ return
1968
+ }
1969
+ const stream = fs.createReadStream(servePath)
1970
+ stream.pipe(res)
1971
+ stream.on('error', () => {
1972
+ try {
1973
+ res.end()
1974
+ } catch {}
1975
+ })
1976
+ })
1977
+ })
1978
+ }
1979
+
1980
+ private sweepIdleCliClients() {
1981
+ const now = Date.now()
1982
+ let swept = false
1983
+ for (const [ws, simId] of this.cliSimBySocket) {
1984
+ const lastCommand = this.cliLastCommandAt.get(ws) ?? 0
1985
+ if (now - lastCommand < SootSimBridgeHost.CLI_IDLE_TIMEOUT_MS) continue
1986
+ // a long-running forwarded command (e.g. a multi-minute path-traced
1987
+ // three-mode render) is still activity: sweeping its socket while the
1988
+ // sim is working kills the render with "idle timeout". only sweep
1989
+ // sockets with no in-flight command awaiting a sim response.
1990
+ let hasPendingCommand = false
1991
+ for (const entry of this.cliBySentId.values()) {
1992
+ if (entry.ws === ws) {
1993
+ hasPendingCommand = true
1994
+ break
1995
+ }
1996
+ }
1997
+ if (hasPendingCommand) continue
1998
+ this.cliSimBySocket.delete(ws)
1999
+ this.cliLastCommandAt.delete(ws)
2000
+ for (const [sentId, entry] of this.cliBySentId) {
2001
+ if (entry.ws === ws) this.cliBySentId.delete(sentId)
2002
+ }
2003
+ try {
2004
+ ws.close(1000, 'idle timeout')
2005
+ } catch {}
2006
+ swept = true
2007
+ }
2008
+ if (swept) {
2009
+ this.broadcastSimClientStates()
2010
+ }
2011
+ this.sweepRestorableSims(now)
2012
+ this.reapIdleSims(now)
2013
+ }
2014
+
2015
+ // GC abandoned sims so `rnx list` stays usable across long dev / QA /
2016
+ // test sessions (F21-3). conservative on purpose: a sim is only reaped
2017
+ // when every "someone cares about this" signal is absent.
2018
+ //
2019
+ // NOTE: `sim.userFocused` is deliberately NOT an exemption. focus is
2020
+ // advisory in this design — see updateUserFocusLease: "focus alone never
2021
+ // creates a blocking lease … use updateUserActivity() to lock on real
2022
+ // interaction instead." real interaction (pointer/key/wheel/touch)
2023
+ // refreshes a `user-active` lease, which the getActiveLease() guard below
2024
+ // already honors. a headless playwright tab reports `focused:true` once
2025
+ // and never `false` (nothing else competes for focus in headless), so a
2026
+ // userFocused exemption permanently shielded exactly the abandoned-tab
2027
+ // zombie class F21-3 / F19-2 target (QA F22-2). a sim a human is genuinely
2028
+ // using is the primary and/or holds a fresh user-active lease; one that is
2029
+ // none of those and idle past the TTL is abandoned regardless of a stale
2030
+ // focus flag.
2031
+ // automation sims are spawned by the playwright driver (`rnx open
2032
+ // --driver playwright`, the factory/screenshot capture path), which tags them
2033
+ // with `meta.sootsimHostDriver === 'playwright'` via the host's addInitScript.
2034
+ // they are unattended one-shot browsers, never a human's tab, so they get a
2035
+ // shorter idle TTL and a concurrent cap.
2036
+ private isAutomationSim(sim: BridgeSimConnection): boolean {
2037
+ return sim.meta?.sootsimHostDriver === 'playwright'
2038
+ }
2039
+
2040
+ // an automation sim whose spawning playwright host process is still alive is
2041
+ // an ACTIVE run, not an abandoned tab. mirrors the cli's playwrightHostPidForSim
2042
+ // (control.ts): extract the host pid from meta, then check liveness so the
2043
+ // host-side idle GC never kills a live capture/factory sim mid-run.
2044
+ private automationSimOwnerAlive(sim: BridgeSimConnection): boolean {
2045
+ const pid = Number(sim.meta?.sootsimHostPid)
2046
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false
2047
+ try {
2048
+ process.kill(pid, 0) // ESRCH → dead; EPERM → alive but unsignalable
2049
+ return true
2050
+ } catch (err) {
2051
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
2052
+ }
2053
+ }
2054
+
2055
+ private reapIdleSims(now = Date.now()) {
2056
+ const attachedSimIds = new Set(this.cliSimBySocket.values())
2057
+ // automation sims that pass every "someone cares" gate but are still under
2058
+ // their TTL — candidates for the concurrent-cap pass below.
2059
+ const automationLive: { sim: BridgeSimConnection; idleMs: number }[] = []
2060
+ for (const sim of this.sims.values()) {
2061
+ if (sim.id === this.primarySimId) continue
2062
+ if (attachedSimIds.has(sim.id)) continue
2063
+ if (this.getActiveLease(sim)) continue
2064
+ // never-CLI-driven sims have lastActiveAt 0 — fall back to connectedAt
2065
+ // so a freshly opened, not-yet-primary tab isn't reaped on the spot.
2066
+ const lastTouched = Math.max(sim.lastActiveAt, sim.connectedAt)
2067
+ const idleMs = now - lastTouched
2068
+ const automation = this.isAutomationSim(sim)
2069
+ // a live-owner automation sim is an ACTIVE run (its spawning playwright host
2070
+ // process is still alive) — never reap it. the host keeps Chrome alive
2071
+ // intentionally and tears it down itself when ITS owner driver dies; only
2072
+ // orphaned/host-dead autos are the leak this reaper targets. (2026-06: the
2073
+ // 10-min automation TTL was reaping live capture + factory sims mid-run —
2074
+ // too aggressive.) continue skips both the TTL reap and the cap pass below.
2075
+ if (automation && this.automationSimOwnerAlive(sim)) continue
2076
+ const ttl = automation ? this.automationSimIdleReapTtlMs : this.simIdleReapTtlMs
2077
+ if (idleMs >= ttl) {
2078
+ // terminal close → client closes the window, does not reconnect; the
2079
+ // 'close' handler runs rememberDisconnectedSim → this.sims.delete.
2080
+ this.closeSimSocketFromHost(sim.ws)
2081
+ continue
2082
+ }
2083
+ if (automation) automationLive.push({ sim, idleMs })
2084
+ }
2085
+ // concurrent cap: a burst of capture opens can pile up hundreds inside the
2086
+ // TTL window. keep at most maxConcurrentAutomationSims of the freshest;
2087
+ // reap the most-idle overflow, but never one active within the grace (an
2088
+ // agent mid-capture between screenshots).
2089
+ const cap = this.maxConcurrentAutomationSims
2090
+ if (automationLive.length > cap) {
2091
+ automationLive.sort((a, b) => b.idleMs - a.idleMs) // most-idle first
2092
+ for (const { sim, idleMs } of automationLive.slice(
2093
+ 0,
2094
+ automationLive.length - cap,
2095
+ )) {
2096
+ if (idleMs < this.automationSimActiveGraceMs) continue
2097
+ this.closeSimSocketFromHost(sim.ws)
2098
+ }
2099
+ }
2100
+ }
2101
+
2102
+ // ping every connected ws; if the previous round's ping went unanswered,
2103
+ // terminate the socket so 'close' fires and the sim cleanup path
2104
+ // runs. matches the recommended ws-library heartbeat pattern.
2105
+ private sweepDeadWebSockets() {
2106
+ if (!this.wss) return
2107
+ for (const ws of this.wss.clients) {
2108
+ if (ws.readyState !== WebSocket.OPEN) continue
2109
+ const alive = this.wsIsAlive.get(ws)
2110
+ if (alive === false) {
2111
+ try {
2112
+ ws.terminate()
2113
+ } catch {}
2114
+ continue
2115
+ }
2116
+ this.wsIsAlive.set(ws, false)
2117
+ try {
2118
+ ws.ping()
2119
+ } catch {
2120
+ try {
2121
+ ws.terminate()
2122
+ } catch {}
2123
+ }
2124
+ }
2125
+ }
2126
+
2127
+ private closeSimSocketFromHost(ws: WebSocket) {
2128
+ if (ws.readyState !== WebSocket.OPEN) return
2129
+ try {
2130
+ ws.close(SOOTSIM_BRIDGE_SIM_CLOSE_CODE, SOOTSIM_BRIDGE_SIM_CLOSE_REASON)
2131
+ } catch {
2132
+ try {
2133
+ ws.terminate()
2134
+ } catch {}
2135
+ return
2136
+ }
2137
+ const terminateTimer = setTimeout(() => {
2138
+ if (ws.readyState === WebSocket.CLOSED) return
2139
+ try {
2140
+ ws.terminate()
2141
+ } catch {}
2142
+ }, FORCE_CLOSE_TERMINATE_MS)
2143
+ unrefTimer(terminateTimer)
2144
+ }
2145
+
2146
+ listSims(viewerIdentityKey?: string): BridgeSimInfo[] {
2147
+ return Array.from(this.sims.values())
2148
+ .sort((a, b) => {
2149
+ if (a.id === this.primarySimId) return -1
2150
+ if (b.id === this.primarySimId) return 1
2151
+ return a.connectedAt - b.connectedAt
2152
+ })
2153
+ .map((sim) => this.describeSim(sim, viewerIdentityKey))
2154
+ }
2155
+
2156
+ async sendCommand(cmd: Omit<BridgeSimCommand, 'id'>): Promise<any> {
2157
+ const sim = await this.waitForSim(cmd.simId)
2158
+ const id = this.nextCommandId++
2159
+ return new Promise((resolve, reject) => {
2160
+ const timeout = setTimeout(() => {
2161
+ this.pendingCommands.delete(id)
2162
+ this.broadcastSimClientStates()
2163
+ reject(new Error('command timed out after 30s'))
2164
+ }, 30000)
2165
+
2166
+ this.pendingCommands.set(id, {
2167
+ simId: sim.id,
2168
+ resolve: (value) => {
2169
+ clearTimeout(timeout)
2170
+ this.pendingCommands.delete(id)
2171
+ this.broadcastSimClientStates()
2172
+ resolve(value)
2173
+ },
2174
+ reject: (error) => {
2175
+ clearTimeout(timeout)
2176
+ this.pendingCommands.delete(id)
2177
+ this.broadcastSimClientStates()
2178
+ reject(error)
2179
+ },
2180
+ })
2181
+ this.broadcastSimClientStates()
2182
+
2183
+ const { simId: _simId, ...forwarded } = cmd
2184
+ sim.ws.send(JSON.stringify({ ...forwarded, id }))
2185
+ })
2186
+ }
2187
+
2188
+ async evaluate(code: string, simId?: string): Promise<any> {
2189
+ return this.sendCommand({ type: 'evaluate', code, simId })
2190
+ }
2191
+
2192
+ async focusSim(simId?: string): Promise<any> {
2193
+ return this.sendCommand({ type: 'focus', simId })
2194
+ }
2195
+
2196
+ async closeSim(simId?: string): Promise<any> {
2197
+ return this.sendCommand({ type: 'close', simId })
2198
+ }
2199
+
2200
+ async openPathInEditor(
2201
+ filePath: string,
2202
+ line?: number,
2203
+ column?: number,
2204
+ ): Promise<void> {
2205
+ const loc = line != null ? `:${line}${column != null ? `:${column}` : ''}` : ''
2206
+ const target = `${filePath}${loc}`
2207
+
2208
+ const trySpawn = (cmd: string, args: string[]) =>
2209
+ new Promise<boolean>((resolve) => {
2210
+ try {
2211
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' })
2212
+ let settled = false
2213
+ child.on('error', () => {
2214
+ if (settled) return
2215
+ settled = true
2216
+ resolve(false)
2217
+ })
2218
+ child.on('spawn', () => {
2219
+ if (settled) return
2220
+ settled = true
2221
+ child.unref()
2222
+ resolve(true)
2223
+ })
2224
+ } catch {
2225
+ resolve(false)
2226
+ }
2227
+ })
2228
+
2229
+ // prefer the editor the user told us about, then common cli wrappers,
2230
+ // then fall back to the plain `open` path.
2231
+ const envEditor = process.env.REACT_EDITOR || process.env.EDITOR
2232
+ if (envEditor) {
2233
+ const parts = envEditor.split(' ').filter(Boolean)
2234
+ if (parts.length && (await trySpawn(parts[0], [...parts.slice(1), '-g', target])))
2235
+ return
2236
+ }
2237
+ if (await trySpawn('cursor', ['-g', target])) return
2238
+ if (await trySpawn('code', ['-g', target])) return
2239
+ await this.openUrl(filePath)
2240
+ }
2241
+
2242
+ async openUrl(url: string, options: OpenUrlOptions = {}): Promise<void> {
2243
+ if (this.openUrlHandler) {
2244
+ await this.openUrlHandler(url, options)
2245
+ return
2246
+ }
2247
+ await openUrlInBrowser(url, options)
2248
+ }
2249
+
2250
+ async close() {
2251
+ if (this.cliIdleTimer) {
2252
+ clearInterval(this.cliIdleTimer)
2253
+ this.cliIdleTimer = null
2254
+ }
2255
+ if (this.heartbeatTimer) {
2256
+ clearInterval(this.heartbeatTimer)
2257
+ this.heartbeatTimer = null
2258
+ }
2259
+ if (this.devHeartbeatTimer) {
2260
+ clearInterval(this.devHeartbeatTimer)
2261
+ this.devHeartbeatTimer = null
2262
+ }
2263
+ if (this.wsHeartbeatTimer) {
2264
+ clearInterval(this.wsHeartbeatTimer)
2265
+ this.wsHeartbeatTimer = null
2266
+ }
2267
+ if (this.runtimeUpdateTimer) {
2268
+ clearInterval(this.runtimeUpdateTimer)
2269
+ this.runtimeUpdateTimer = null
2270
+ }
2271
+ if (this.shouldWriteLockfile) {
2272
+ try {
2273
+ removeDaemonLockfile({ pid: process.pid, startedAt: this.startedAt })
2274
+ } catch {}
2275
+ }
2276
+ if (this.shouldWriteDevLockfile) {
2277
+ try {
2278
+ removeDevBridgeLockfile({
2279
+ bridgePort: this.effectivePort,
2280
+ pid: process.pid,
2281
+ startedAt: this.startedAt,
2282
+ })
2283
+ } catch {}
2284
+ }
2285
+ this.effectivePort = 0
2286
+ this.startedAt = 0
2287
+ this.agentHost.close()
2288
+ for (const [id, pending] of this.pendingCommands) {
2289
+ pending.reject(new Error('server closing'))
2290
+ this.pendingCommands.delete(id)
2291
+ }
2292
+ for (const sim of this.sims.values()) {
2293
+ sim.ws.close()
2294
+ }
2295
+ this.sims.clear()
2296
+ this.primarySimId = null
2297
+ const wss = this.wss
2298
+ const httpServer = this.httpServer
2299
+ this.wss = null
2300
+ this.httpServer = null
2301
+ if (wss) {
2302
+ try {
2303
+ wss.close()
2304
+ } catch {}
2305
+ }
2306
+ // resolve only once the port is actually released, so a caller awaiting
2307
+ // close() can rebind or assert on it without polling.
2308
+ if (httpServer) {
2309
+ httpServer.closeAllConnections()
2310
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()))
2311
+ }
2312
+ }
2313
+
2314
+ private describeSim(
2315
+ sim: BridgeSimConnection,
2316
+ viewerIdentityKey?: string,
2317
+ ): BridgeSimInfo {
2318
+ let readyState: BridgeSimConnection['ws']['readyState']
2319
+ try {
2320
+ readyState = sim.ws.readyState
2321
+ } catch {
2322
+ readyState = WebSocket.CLOSED
2323
+ }
2324
+ const lease = this.getActiveLease(sim)
2325
+ return {
2326
+ id: sim.id,
2327
+ origin: sim.origin,
2328
+ url: sim.url,
2329
+ title: sim.title,
2330
+ userAgent: sim.userAgent,
2331
+ connectedAt: sim.connectedAt,
2332
+ lastSeenAt: sim.lastSeenAt,
2333
+ lastActiveAt: sim.lastActiveAt || undefined,
2334
+ isPrimary: sim.id === this.primarySimId,
2335
+ readyState:
2336
+ readyState === WebSocket.OPEN
2337
+ ? 'open'
2338
+ : readyState === WebSocket.CLOSING
2339
+ ? 'closing'
2340
+ : 'closed',
2341
+ attachedCliCount: this.getAttachedCliCount(sim.id),
2342
+ lockedBy: lease ? lease.cliLabel || lease.cliIdentityKey : undefined,
2343
+ lockedByMe: lease ? lease.cliIdentityKey === viewerIdentityKey : undefined,
2344
+ lockedByKind: lease ? lease.kind : undefined,
2345
+ lockExpiresAt: lease ? lease.expiresAt : undefined,
2346
+ userFocused: sim.userFocused || undefined,
2347
+ userVisible: sim.userVisible,
2348
+ visibilityState: sim.visibilityState,
2349
+ documentFocused: sim.documentFocused,
2350
+ kind: sim.kind,
2351
+ meta: sim.meta,
2352
+ }
2353
+ }
2354
+
2355
+ private getActiveLease(sim: BridgeSimConnection): BridgeCliLease | null {
2356
+ const lease = sim.cliLease
2357
+ if (!lease) return null
2358
+ if (Date.now() >= lease.expiresAt) {
2359
+ sim.cliLease = undefined
2360
+ return null
2361
+ }
2362
+ return lease
2363
+ }
2364
+
2365
+ private tryAcquireLease(
2366
+ ws: WebSocket,
2367
+ sim: BridgeSimConnection,
2368
+ opts: { force?: boolean } = {},
2369
+ ): {
2370
+ granted: boolean
2371
+ lease: BridgeCliLease
2372
+ lock?: BridgeLockInfo
2373
+ bootedCount: number
2374
+ } {
2375
+ const cliIdentityKey =
2376
+ this.cliIdentityKeyBySocket.get(ws) ??
2377
+ (() => {
2378
+ const fallback = `ws-${this.nextCliFallbackId++}`
2379
+ this.cliIdentityKeyBySocket.set(ws, fallback)
2380
+ return fallback
2381
+ })()
2382
+ const cliLabel = this.cliLabelBySocket.get(ws)
2383
+ const now = Date.now()
2384
+ const existing = this.getActiveLease(sim)
2385
+ const ownerMatches = existing && existing.cliIdentityKey === cliIdentityKey
2386
+ let bootedCount = 0
2387
+
2388
+ if (existing && !ownerMatches && !opts.force) {
2389
+ return {
2390
+ granted: false,
2391
+ lease: existing,
2392
+ lock: {
2393
+ by: existing.cliLabel || existing.cliIdentityKey,
2394
+ expiresInMs: Math.max(0, existing.expiresAt - now),
2395
+ },
2396
+ bootedCount: 0,
2397
+ }
2398
+ }
2399
+
2400
+ if (existing && !ownerMatches && opts.force) {
2401
+ // boot other CLI sockets attached to this sim that don't share the lease key
2402
+ for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
2403
+ if (attachedSimId !== sim.id) continue
2404
+ const otherKey = this.cliIdentityKeyBySocket.get(cliWs)
2405
+ if (otherKey && otherKey !== cliIdentityKey) {
2406
+ this.cliSimBySocket.delete(cliWs)
2407
+ try {
2408
+ cliWs.close(1000, 'lease claimed by another cli')
2409
+ } catch {}
2410
+ bootedCount++
2411
+ }
2412
+ }
2413
+ }
2414
+
2415
+ const lease: BridgeCliLease = {
2416
+ kind: 'cli',
2417
+ cliIdentityKey,
2418
+ cliLabel,
2419
+ expiresAt: now + SootSimBridgeHost.CLI_LEASE_TTL_MS,
2420
+ }
2421
+ sim.cliLease = lease
2422
+ return { granted: true, lease, bootedCount }
2423
+ }
2424
+
2425
+ // user focus is advisory: we track it on the sim record so list/UI can
2426
+ // show "focused" alongside any cli lease, but focus alone never creates a
2427
+ // blocking lease. the old 15s user-focus lease meant clicking on the sim
2428
+ // locked out agent inspect calls for 15s — the opposite of what you want
2429
+ // when debugging something the user is actively looking at. use
2430
+ // updateUserActivity() to lock on real interaction instead.
2431
+ private updateUserFocusLease(
2432
+ sim: BridgeSimConnection,
2433
+ focusState: BridgeSimUserFocusStateMessage,
2434
+ ) {
2435
+ const nextFocused = focusState.focused === true
2436
+ const nextVisible =
2437
+ typeof focusState.visible === 'boolean' ? focusState.visible : undefined
2438
+ const nextVisibilityState =
2439
+ typeof focusState.visibilityState === 'string'
2440
+ ? focusState.visibilityState
2441
+ : undefined
2442
+ const nextDocumentFocused =
2443
+ typeof focusState.documentFocused === 'boolean'
2444
+ ? focusState.documentFocused
2445
+ : undefined
2446
+ if (
2447
+ sim.userFocused === nextFocused &&
2448
+ sim.userVisible === nextVisible &&
2449
+ sim.visibilityState === nextVisibilityState &&
2450
+ sim.documentFocused === nextDocumentFocused
2451
+ ) {
2452
+ return
2453
+ }
2454
+ sim.userFocused = nextFocused
2455
+ sim.userVisible = nextVisible
2456
+ sim.visibilityState = nextVisibilityState
2457
+ sim.documentFocused = nextDocumentFocused
2458
+ this.broadcastSimClientStates()
2459
+ }
2460
+
2461
+ // called when the sim reports a real user interaction (pointerdown,
2462
+ // keydown, wheel, touch). creates or refreshes a short `user-active` lease
2463
+ // that keeps agent writes from trampling a user who is driving the sim.
2464
+ // reads still pass through — shouldAcquireLease only blocks on writes.
2465
+ private updateUserActivity(sim: BridgeSimConnection) {
2466
+ const existing = this.getActiveLease(sim)
2467
+ if (existing && existing.kind === 'cli') {
2468
+ // a real cli lease wins — don't shadow an agent that's actively
2469
+ // driving the sim with a user-active lease.
2470
+ return
2471
+ }
2472
+ const now = Date.now()
2473
+ const refreshed = now + SootSimBridgeHost.USER_ACTIVE_LEASE_TTL_MS
2474
+ // if a longer user-active hold is already in effect (e.g. from an explicit
2475
+ // boot), keep it — passive canvas interaction must not shorten it.
2476
+ const expiresAt =
2477
+ existing && existing.kind === 'user-active'
2478
+ ? Math.max(existing.expiresAt, refreshed)
2479
+ : refreshed
2480
+ sim.cliLease = {
2481
+ kind: 'user-active',
2482
+ cliIdentityKey: '__user-active__',
2483
+ cliLabel: 'active user',
2484
+ expiresAt,
2485
+ }
2486
+ this.broadcastSimClientStates()
2487
+ }
2488
+
2489
+ private ensureCliIdentityKey(ws: WebSocket): string {
2490
+ const existing = this.cliIdentityKeyBySocket.get(ws)
2491
+ if (existing) return existing
2492
+ const fallback = `ws-${this.nextCliFallbackId++}`
2493
+ this.cliIdentityKeyBySocket.set(ws, fallback)
2494
+ return fallback
2495
+ }
2496
+
2497
+ private getOpenSim(simId?: string): BridgeSimConnection | null {
2498
+ if (simId) {
2499
+ const sim = this.sims.get(simId)
2500
+ if (sim?.ws.readyState === WebSocket.OPEN) return sim
2501
+ return null
2502
+ }
2503
+ const candidates = this.getDefaultSimCandidates()
2504
+ return candidates.find((sim) => sim.id === this.primarySimId) ?? candidates[0] ?? null
2505
+ }
2506
+
2507
+ private getDefaultSimCandidates(): BridgeSimConnection[] {
2508
+ const open = Array.from(this.sims.values()).filter(
2509
+ (sim) => sim.ws.readyState === WebSocket.OPEN,
2510
+ )
2511
+ // a registered page is driveable; a bare host-shell socket is not. use
2512
+ // page-less sockets only when no registered page exists, matching the
2513
+ // default-routing behavior above.
2514
+ const registered = open.filter((sim) => sim.url)
2515
+ return registered.length > 0 ? registered : open
2516
+ }
2517
+
2518
+ private async waitForSim(
2519
+ simId?: string,
2520
+ options: { attempts?: number; intervalMs?: number } = {},
2521
+ ): Promise<BridgeSimConnection> {
2522
+ const attempts = options.attempts ?? 10
2523
+ const intervalMs = options.intervalMs ?? 200
2524
+ for (let attempt = 0; attempt < attempts; attempt++) {
2525
+ if (!simId) {
2526
+ const candidates = this.getDefaultSimCandidates()
2527
+ if (candidates.length > 1) {
2528
+ throw new Error(
2529
+ `multiple sims are connected: ${candidates.map((sim) => sim.id).join(', ')}; ` +
2530
+ 'run `rnx use <sim>` or pass `--sim <sim>`',
2531
+ )
2532
+ }
2533
+ const candidate = candidates[0]
2534
+ if (candidate) return candidate
2535
+ } else {
2536
+ const sim = this.getOpenSim(simId)
2537
+ if (sim) return sim
2538
+ }
2539
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
2540
+ }
2541
+ if (!simId) throw new Error('no sim connected')
2542
+ const openSimIds = this.getDefaultSimCandidates().map((sim) => sim.id)
2543
+ throw new Error(
2544
+ `no sim connected with id ${simId}` +
2545
+ (openSimIds.length > 0 ? `; connected sims: ${openSimIds.join(', ')}` : ''),
2546
+ )
2547
+ }
2548
+
2549
+ private shouldPromoteSim(sim: BridgeSimConnection): boolean {
2550
+ const current = this.primarySimId ? this.sims.get(this.primarySimId) : null
2551
+ // a sim that has not yet reported a page (no `url`) is a bare WS
2552
+ // connection — it can't render or be driven. promoting one makes it the
2553
+ // default target and poisons every un-`--sim` command (QA F19-2): a
2554
+ // zombie that connected from the dev shell but never sent
2555
+ // `bridge:register` used to grab primary at connect-time and keep it
2556
+ // for hours. only let a page-less sim be primary as a last resort when
2557
+ // there is no current primary at all (transient — replaced as soon as a
2558
+ // real page registers, via the re-election in `bridge:register`).
2559
+ if (!sim.url) return !current
2560
+ const currentAlive = current?.ws.readyState === WebSocket.OPEN
2561
+ // a registered (url-bearing) sim always supersedes a dead or page-less
2562
+ // primary; among live page-bearing sims the dev-shell (:5173) origin
2563
+ // wins, matching the previous primary-candidate intent.
2564
+ if (!current || !currentAlive || !current.url) return true
2565
+ const isPrimaryCandidate = sim.origin?.includes(':5173')
2566
+ const currentIsPrimary = current.origin?.includes(':5173')
2567
+ return !!isPrimaryCandidate || !currentIsPrimary
2568
+ }
2569
+
2570
+ private broadcastSimAssignments() {
2571
+ for (const sim of this.sims.values()) {
2572
+ if (sim.ws.readyState !== WebSocket.OPEN) continue
2573
+ sim.ws.send(
2574
+ JSON.stringify({
2575
+ type: 'bridge:welcome',
2576
+ simId: sim.id,
2577
+ isPrimary: sim.id === this.primarySimId,
2578
+ }),
2579
+ )
2580
+ }
2581
+ }
2582
+
2583
+ private writeAndBroadcastSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
2584
+ const next = writeSharedConfig(patch)
2585
+ const payload = JSON.stringify({
2586
+ type: 'bridge:shared-config-changed',
2587
+ config: next,
2588
+ })
2589
+ for (const peer of this.sims.values()) {
2590
+ if (peer.ws.readyState !== WebSocket.OPEN) continue
2591
+ try {
2592
+ peer.ws.send(payload)
2593
+ } catch {}
2594
+ }
2595
+ return next
2596
+ }
2597
+
2598
+ private broadcastSimClientStates() {
2599
+ for (const sim of this.sims.values()) {
2600
+ if (sim.ws.readyState !== WebSocket.OPEN) continue
2601
+ const lease = this.getActiveLease(sim)
2602
+ const message: BridgeSimClientStateMessage = {
2603
+ type: 'bridge:client-state',
2604
+ attachedCliCount: this.getAttachedCliCount(sim.id),
2605
+ activeAgentCommandCount: this.getActiveAgentCommandCount(sim.id),
2606
+ recentActions: sim.recentActions,
2607
+ lockedBy: lease ? lease.cliLabel || lease.cliIdentityKey : undefined,
2608
+ lockedByKind: lease ? lease.kind : undefined,
2609
+ lockExpiresAt: lease ? lease.expiresAt : undefined,
2610
+ userFocused: sim.userFocused || undefined,
2611
+ userVisible: sim.userVisible,
2612
+ visibilityState: sim.visibilityState,
2613
+ documentFocused: sim.documentFocused,
2614
+ }
2615
+ sim.ws.send(JSON.stringify(message))
2616
+ }
2617
+ }
2618
+
2619
+ private setCliSimTarget(ws: WebSocket, simId: string) {
2620
+ const prevSimId = this.cliSimBySocket.get(ws)
2621
+ if (prevSimId === simId) return
2622
+ this.cliSimBySocket.set(ws, simId)
2623
+ this.recordSimAction(simId, prevSimId ? 'cli switched sims' : 'cli connected', false)
2624
+ this.broadcastSimClientStates()
2625
+ }
2626
+
2627
+ private recordSimAction(
2628
+ simId: string,
2629
+ label: string | null | undefined,
2630
+ broadcast = true,
2631
+ ) {
2632
+ const normalized = label?.trim()
2633
+ if (!normalized) return
2634
+ const sim = this.sims.get(simId)
2635
+ if (!sim) return
2636
+ const now = Date.now()
2637
+ sim.lastActiveAt = now
2638
+ sim.recentActions = [
2639
+ { label: normalized, at: now },
2640
+ ...sim.recentActions.filter((entry) => entry.label !== normalized),
2641
+ ].slice(0, 4)
2642
+ if (broadcast) this.broadcastSimClientStates()
2643
+ }
2644
+
2645
+ private describeForwardedCommand(msg: any): string | null {
2646
+ switch (msg?.type) {
2647
+ case 'evaluate':
2648
+ return 'evaluated page state'
2649
+ case 'screenshot':
2650
+ return 'captured screenshot'
2651
+ case 'capture':
2652
+ return 'captured app screen'
2653
+ case 'tap':
2654
+ return 'sent tap event'
2655
+ case 'keyboard':
2656
+ return msg?.action === 'type' ? 'typed text' : 'used keyboard'
2657
+ case 'tree':
2658
+ return 'dumped tree'
2659
+ case 'storageSnapshot':
2660
+ return 'dumped storage snapshot'
2661
+ case 'query':
2662
+ return 'queried semantic tree'
2663
+ case 'resolve':
2664
+ return 'resolved semantic selector'
2665
+ case 'focus':
2666
+ return 'focused sim'
2667
+ case 'close':
2668
+ return 'requested close'
2669
+ default:
2670
+ return typeof msg?.type === 'string' ? msg.type : null
2671
+ }
2672
+ }
2673
+
2674
+ // count distinct cli identity keys attached to a sim, not raw sockets.
2675
+ // a single agent firing sequential cli commands opens a new ws per call —
2676
+ // counting sockets would report phantom peers until idle cleanup catches up.
2677
+ private getAttachedCliCount(simId: string): number {
2678
+ const keys = new Set<string>()
2679
+ for (const [ws, attachedSimId] of this.cliSimBySocket) {
2680
+ if (attachedSimId !== simId) continue
2681
+ if (ws.readyState !== WebSocket.OPEN) continue
2682
+ const key = this.cliIdentityKeyBySocket.get(ws)
2683
+ keys.add(key ?? `ws-unknown-${keys.size}`)
2684
+ }
2685
+ return keys.size
2686
+ }
2687
+
2688
+ // count distinct identity keys attached to this sim other than `selfWs`.
2689
+ // used to warn a cli that other agents/identities are also targeting the sim.
2690
+ private getOtherCliIdentityCount(selfWs: WebSocket, simId: string): number {
2691
+ const selfKey = this.cliIdentityKeyBySocket.get(selfWs)
2692
+ const keys = new Set<string>()
2693
+ for (const [ws, attachedSimId] of this.cliSimBySocket) {
2694
+ if (attachedSimId !== simId) continue
2695
+ if (ws.readyState !== WebSocket.OPEN) continue
2696
+ const key = this.cliIdentityKeyBySocket.get(ws)
2697
+ if (key && key === selfKey) continue
2698
+ keys.add(key ?? `ws-unknown-${keys.size}`)
2699
+ }
2700
+ return keys.size
2701
+ }
2702
+
2703
+ private getActiveAgentCommandCount(simId: string): number {
2704
+ let count = 0
2705
+ for (const pending of this.pendingCommands.values()) {
2706
+ if (pending.simId === simId) count++
2707
+ }
2708
+ return count
2709
+ }
2710
+
2711
+ private allocateSimId(): string {
2712
+ for (;;) {
2713
+ const id = this.nextSimNumber.toString(16)
2714
+ this.nextSimNumber++
2715
+ if (!this.sims.has(id) && !this.restorableSims.has(id)) return id
2716
+ }
2717
+ }
2718
+
2719
+ private tryRestoreSimId(
2720
+ sim: BridgeSimConnection,
2721
+ requestedId: string | undefined,
2722
+ ): boolean {
2723
+ const nextId = requestedId?.trim()
2724
+ if (!nextId || nextId === sim.id) return false
2725
+ const existing = this.sims.get(nextId)
2726
+ if (existing && existing !== sim && existing.ws.readyState === WebSocket.OPEN) {
2727
+ return false
2728
+ }
2729
+ const restorable = this.getRestorableSimState(nextId)
2730
+
2731
+ const prevId = sim.id
2732
+ this.sims.delete(prevId)
2733
+ sim.id = nextId
2734
+ if (restorable) {
2735
+ sim.recentActions = restorable.recentActions.map((entry) => ({ ...entry }))
2736
+ sim.lastActiveAt = restorable.lastActiveAt
2737
+ sim.cliLease = restorable.cliLease ? { ...restorable.cliLease } : undefined
2738
+ this.restorableSims.delete(nextId)
2739
+ }
2740
+ this.sims.set(sim.id, sim)
2741
+ if (this.primarySimId === prevId) {
2742
+ this.primarySimId = sim.id
2743
+ }
2744
+ for (const [ws, attachedSimId] of this.cliSimBySocket) {
2745
+ if (attachedSimId === prevId) {
2746
+ this.cliSimBySocket.set(ws, sim.id)
2747
+ }
2748
+ }
2749
+ return true
2750
+ }
2751
+
2752
+ private rememberDisconnectedSim(sim: BridgeSimConnection) {
2753
+ const lease = this.getActiveLease(sim)
2754
+ this.restorableSims.set(sim.id, {
2755
+ recentActions: sim.recentActions.map((entry) => ({ ...entry })),
2756
+ lastActiveAt: sim.lastActiveAt,
2757
+ cliLease: lease && lease.kind === 'cli' ? { ...lease } : undefined,
2758
+ expiresAt: Date.now() + SootSimBridgeHost.SIM_RECONNECT_TTL_MS,
2759
+ })
2760
+ this.sims.delete(sim.id)
2761
+ this.writeConnectedRuntimeSnapshot()
2762
+ }
2763
+
2764
+ private getRestorableSimState(simId: string): BridgeRestorableSimState | null {
2765
+ const snapshot = this.restorableSims.get(simId)
2766
+ if (!snapshot) return null
2767
+ if (snapshot.expiresAt <= Date.now()) {
2768
+ this.restorableSims.delete(simId)
2769
+ return null
2770
+ }
2771
+ if (snapshot.cliLease && snapshot.cliLease.expiresAt <= Date.now()) {
2772
+ snapshot.cliLease = undefined
2773
+ }
2774
+ return snapshot
2775
+ }
2776
+
2777
+ private sweepRestorableSims(now = Date.now()) {
2778
+ for (const [simId, snapshot] of this.restorableSims) {
2779
+ if (snapshot.expiresAt > now) continue
2780
+ this.restorableSims.delete(simId)
2781
+ for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
2782
+ if (attachedSimId === simId) {
2783
+ this.cliSimBySocket.delete(cliWs)
2784
+ }
2785
+ }
2786
+ }
2787
+ }
2788
+ }