sootsim 0.1.82 → 0.1.84

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