rnxsim 0.0.0 → 0.1.313

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