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