// rnx CLI ↔ sim WS bridge // // the rnx runtime opens a WebSocket server (default port 7668) that // accepts JSON commands like `{ type: 'evaluate', code: 'window.__sootsimTest.getNodeCount()' }` // or `{ type: 'call', path: 'SootSim.bridges.mainShell.launchApp', args: ['photos'] }` // and responds with `{ id, result }` or `{ id, error }`. CLI commands use // this to drive a running rnx instance from the terminal. import fs from 'node:fs' import path from 'node:path' import { WebSocket } from 'ws' import { DEFAULT_SOOTSIM_BRIDGE_PORT, resolveSootsimBridgePort, } from '../src/bridge-constants' import { DEFAULT_SOOTSIM_SHELL_URL } from '../src/cli-constants' import { isDaemonLockfileFresh, isDevBridgeLockfileFresh, readDaemonLockfile, readDevBridgeLockfiles, rnxHomeDir, type DevBridgeLockfile, } from '../src/home-paths' import { createCloudBridgeForParsed } from './cloud-client' import { clearCurrentSimId, getCliIdentity, readCurrentSim, readCurrentSimId, saveCurrentSimId, } from './current-sim' import type { WsBridgeCommand } from '../src/bridge-contract' // --- bridge world resolution --------------------------------------------- // // two bridge kinds can coexist on one machine: Vite dev-shell bridges // (~/.rnx/dev-bridges/.json, shells served by Vite) and the installed // daemon runtime (~/.rnx/daemon.json, shell served by the daemon's own // http server). every default — the ws port a command connects to AND the // shell base URL `open` builds its page from — must come from the SAME // world, or `open` polls one bridge while the sim registers on the other // (the 125s-timeout split-brain bug). resolveBridgeWorld is the single // source of that decision: an invocation's explicit offset wins, then its // checkout's dev bridge, then the canonical dev bridge, a fresh daemon, and // finally hardcoded defaults. export interface BridgeWorld { source: 'dev-bridge' | 'daemon' | 'default' bridgePort: number shellBaseUrl: string } // where this invocation is running. the same rnx commands work in four // places, and the placement is what a command consults instead of growing a // second spelling per place. host and local-box share one transport today — // a local box mounts this machine's checkout and drives this machine's // bridge — so they differ in what is allowed, not in where packets go. a // cloud box is the one that moves the endpoint, and it does not exist yet. export type RnxPlacement = 'host' | 'local-box' | 'cloud-box' // set by `rnx box` on the shell's child processes. the box is the only thing // that may set it; nothing infers a placement from cwd or heuristics. export const RNX_PLACEMENT_ENV = 'RNX_PLACEMENT' export function resolvePlacement(): RnxPlacement { return process.env[RNX_PLACEMENT_ENV] === 'local-box' ? 'local-box' : 'host' } /** the ws endpoint a command connects to. the single place a bridge URL is * formed, so a cloud box changes this function rather than every call site. * host and local-box both drive this machine's bridge. * * changing what this returns changes real egress, and the outbound inventory * declares the CALL EXPRESSION rather than the URL, so it cannot see through * this function. a cloud endpoint added here must get its own declaration in * `outbound-endpoints.ts` instead of inheriting the local_development one. */ export function bridgeWsUrl(wsPort: number): string { return `ws://127.0.0.1:${wsPort}` } // resolved once per CLI process so a multi-step command keeps one world for // its entire invocation. separate CLI calls make the same deterministic choice // from the owner-scoped dev records instead of following whichever heartbeat // happened to run last. let resolvedWorld: { homeDir: string; world: BridgeWorld } | null = null function liveDevBridgeLockfiles(): DevBridgeLockfile[] { return readDevBridgeLockfiles().filter((lockfile) => isDevBridgeLockfileFresh(lockfile)) } function checkoutRoot(cwd: string): string | null { let current = path.resolve(cwd) while (true) { if (fs.existsSync(path.join(current, '.git'))) return current const parent = path.dirname(current) if (parent === current) return null current = parent } } function pathContains(parent: string, child: string): boolean { const relative = path.relative(parent, child) return ( relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ) } function resolveDefaultDevBridgeLockfile(): DevBridgeLockfile | null { const lockfiles = liveDevBridgeLockfiles() if (lockfiles.length === 0) return null if ( process.env.SOOTSIM_BRIDGE_PORT !== undefined || process.env.PORT_OFFSET !== undefined ) { const requestedPort = resolveSootsimBridgePort({ explicitPort: process.env.SOOTSIM_BRIDGE_PORT, portOffset: process.env.PORT_OFFSET, }) const requested = lockfiles.find((lockfile) => lockfile.bridgePort === requestedPort) if (requested) return requested } let cwd: string | null = null try { cwd = process.cwd() } catch {} if (cwd) { const currentCheckout = checkoutRoot(cwd) const sameCheckout = lockfiles.filter((lockfile) => { const bridgeCheckout = checkoutRoot(lockfile.cwd) if (currentCheckout && bridgeCheckout) return currentCheckout === bridgeCheckout return pathContains(cwd, lockfile.cwd) || pathContains(lockfile.cwd, cwd) }) if (sameCheckout.length > 0) { return ( sameCheckout.find( (lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT, ) ?? sameCheckout[0] ) } } return ( lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ?? lockfiles[0] ) } export function resolveBridgeWorld(): BridgeWorld { const homeDir = rnxHomeDir() if (resolvedWorld?.homeDir === homeDir) return resolvedWorld.world const world = resolveBridgeWorldFresh() resolvedWorld = { homeDir, world } return world } function resolveBridgeWorldFresh(): BridgeWorld { const dev = resolveDefaultDevBridgeLockfile() if (dev) { return { source: 'dev-bridge', bridgePort: dev.bridgePort, // older lockfile writers don't record the vite http port; the default // shell URL is the dev shell anyway, so the fallback stays coherent. shellBaseUrl: dev.shellPort ? `http://localhost:${dev.shellPort}/` : DEFAULT_SOOTSIM_SHELL_URL, } } const lock = readDaemonLockfile() if (lock && isDaemonLockfileFresh(lock)) { return { source: 'daemon', bridgePort: lock.bridgePort, shellBaseUrl: lock.runtimePort > 0 ? `http://localhost:${lock.runtimePort}/` : DEFAULT_SOOTSIM_SHELL_URL, } } return { source: 'default', bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT, shellBaseUrl: DEFAULT_SOOTSIM_SHELL_URL, } } /** the world a specific bridge port belongs to, or null when no fresh * lockfile claims it. lets `open --port 7669` derive the daemon's shell * base URL instead of mixing worlds. */ export function worldForBridgePort( port: number, ): { source: 'dev-bridge' | 'daemon'; shellBaseUrl: string } | null { const dev = liveDevBridgeLockfiles().find((lockfile) => lockfile.bridgePort === port) if (dev) { return { source: 'dev-bridge', shellBaseUrl: dev.shellPort ? `http://localhost:${dev.shellPort}/` : DEFAULT_SOOTSIM_SHELL_URL, } } const lock = readDaemonLockfile() if (lock && isDaemonLockfileFresh(lock) && lock.bridgePort === port) { return { source: 'daemon', shellBaseUrl: lock.runtimePort > 0 ? `http://localhost:${lock.runtimePort}/` : DEFAULT_SOOTSIM_SHELL_URL, } } return null } /** the bridge port of the world that serves a given shell base URL, or null * when no fresh lockfile claims it. lets `open --base-url` (without --port) * poll the bridge that actually belongs to that shell. */ export function bridgePortForShellBaseUrl(baseUrl: string): number | null { let port: number try { const url = new URL(baseUrl) port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80 } catch { return null } if (!Number.isFinite(port) || port <= 0) return null const dev = liveDevBridgeLockfiles().find( (lockfile) => lockfile.shellPort === port || lockfile.runtimePort === port || lockfile.bridgePort === port, ) if (dev) { return dev.bridgePort } const lock = readDaemonLockfile() if ( lock && isDaemonLockfileFresh(lock) && (lock.runtimePort === port || lock.bridgePort === port) ) { return lock.bridgePort } return null } /** the bridge a `--base-url` pin implies. one rule for every command that takes * the flag, because they used to disagree: flow refused an unserved pin while * open kept the discovered default and then started a bridge of its own, so * the page rendered in the pinned shell and the polling waited on another * world's bridge. that reads as "saved sim is gone" and gets retried * rather than fixed. an explicit --port states the world outright and wins; * otherwise the pin has to name a world something serves. */ export function resolveBridgePortForPin(pin: { baseUrl: string wsPort: number explicitPort: boolean }): { port: number; error?: undefined } | { port?: undefined; error: string } { const served = bridgePortForShellBaseUrl(pin.baseUrl) if (served === null) { if (pin.explicitPort) return { port: pin.wsPort } return { error: `no live rnx world serves ${pin.baseUrl} — start that shell first, ` + 'or name its bridge with --port ', } } if (pin.explicitPort && pin.wsPort !== served) { return { error: `--base-url ${pin.baseUrl} belongs to bridge port ${served}, but --port ${pin.wsPort} names another world`, } } return { port: served } } /** default ws bridge port for CLI commands: the fresh dev bridge when a vite * shell is live, else the fresh daemon, else the hardcoded default. one * deterministic answer regardless of how the CLI was invoked (global binary * vs repo checkout) — invocation-path heuristics made `rnx list` flap * between worlds. */ export function resolveDefaultBridgePort(): number { return resolveBridgeWorld().bridgePort } /** one-line stderr notice naming the bridge world a command resolved, so the * dev-bridge vs daemon pick is never silent (the split-brain bug hid behind * exactly this silence). pass the shell base URL actually used when the * caller resolved one (open); otherwise the port's own world URL prints. */ export function printBridgeWorldNotice( bridgePort: number, shellBaseUrl?: string | null, ): void { const world = worldForBridgePort(bridgePort) const kind = world?.source === 'dev-bridge' ? 'dev bridge' : world?.source === 'daemon' ? 'daemon bridge' : 'bridge' const origin = world?.source === 'dev-bridge' ? `dev-bridges/${bridgePort}.json` : world?.source === 'daemon' ? 'daemon.json' : 'no lockfile' const shell = shellBaseUrl ?? world?.shellBaseUrl process.stderr.write( ` → ${kind} :${bridgePort} (${shell ? `shell ${shell}, ` : ''}${origin})\n`, ) } export interface WsBridge { readonly plane?: 'local' | 'cloud' // `opts.timeoutMs` overrides the bridge-wide commandTimeoutMs for a single // call — used for fast readiness probes so a dead/unresponsive sim fails in // seconds instead of stalling the full command timeout. send(cmd: WsBridgeCommand, opts?: { timeoutMs?: number }): Promise listSims(): Promise // a guest reload retires the socket and the same page reconnects under a new // or restored id. a caller that pins an id resolves that target here; null // means nothing proves what replaced it. resolveReloadedSim(retiredSimId: string): Promise focusSim(simId?: string): Promise closeSim(simId?: string): Promise claim(simId?: string, opts?: { force?: boolean }): Promise close(): void } export interface BridgeClaimResult { simId: string lockedBy: string lockExpiresAt: number bootedCount: number } export interface BridgeLockInfo { by: string expiresInMs: number } export class BridgeSimLockedError extends Error { lock: BridgeLockInfo constructor(message: string, lock: BridgeLockInfo) { super(message) this.name = 'BridgeSimLockedError' this.lock = lock } } interface WsResponse { id: number result?: any error?: string } interface WsBridgeOptions { commandTimeoutMs?: number simId?: string cliLabel?: string simIdSource?: ParsedBridgeCliArgs['simIdSource'] } // label shown in the target-sim notice. explains where the simId came // from so an agent can tell "I asked for a7" vs "I got default primary". function describeSimIdSource( source: ParsedBridgeCliArgs['simIdSource'] | undefined, ): string { if (source === 'flag') return 'via --sim' if (source === 'saved') return 'saved via `rnx use`' return 'primary fallback — no sim pinned' } export interface BridgeSimInfo { id: string origin?: string url?: string title?: string userAgent?: string connectedAt: number lastSeenAt: number lastActiveAt?: number isPrimary: boolean readyState: 'open' | 'closing' | 'closed' attachedCliCount?: number lockedBy?: string lockedByKind?: 'cli' | 'user-active' lockExpiresAt?: number /** true when this sim's lease belongs to us. computed server-side. */ lockedByMe?: boolean userFocused?: boolean userVisible?: boolean visibilityState?: string documentFocused?: boolean /** registration kind — 'sootsim' (default) or 'contrast'. used by the * `contrast` CLI to filter, and by tooling that wants to distinguish a * Contrast IDE tab from a regular rnx runtime. */ kind?: string /** opaque metadata supplied at register time (projectId, route, the * attached iOS sim id, etc). free-form by design. */ meta?: Record } // a lock owner is either a friendly cli label ("active user") or a raw cli // identity key ("CLAUDE_CODE_SESSION_ID:", "gppid-1234"). keep the list // readable by collapsing a ":" identity to a short form that // still says which agent holds it. export function formatLockOwner(owner: string): string { const colon = owner.indexOf(':') if (colon <= 0) return owner.length > 24 ? `${owner.slice(0, 21)}…` : owner const source = owner.slice(0, colon) const value = owner.slice(colon + 1) const short = value.length > 12 ? `${value.slice(0, 8)}…` : value if ( source === 'TM_SESSION' || source === 'CLAUDE_CODE_SESSION_ID' || source === 'CODEX_THREAD_ID' ) { return `agent ${short}` } return `${source} ${short}` } interface ParseBridgeCliArgsOptions { port?: number commandTimeoutMs?: number stripBooleanFlags?: string[] stripValueFlags?: string[] } export interface ParsedBridgeCliArgs { positional: string[] wsPort: number explicitPort: boolean simId?: string simIdSource: 'flag' | 'saved' | 'none' commandTimeoutMs: number } interface BridgeFlagState { wsPort: number explicitPort: boolean commandTimeoutMs: number simId: string | undefined simIdSource: ParsedBridgeCliArgs['simIdSource'] } // every flag parseBridgeCliArgs consumes a value for on its own, without being // listed in a caller's stripValueFlags. the parser iterates this table, so the // exported name list below cannot drift from what is actually parsed. a name // missing from that list is not a parse failure, it is a command that hunts a // positional in raw argv and takes the flag's value instead: `maestro test // --newflag value login.yaml` would run `value` as the flow. const BRIDGE_VALUE_FLAGS_TABLE: readonly { names: readonly string[] apply: (state: BridgeFlagState, value: string | undefined) => void }[] = [ { names: ['--port', '-p'], // naming the flag pins the world even when the value is missing or junk, // so a later --base-url cannot quietly retarget the run to another shell. apply: (state, value) => { state.explicitPort = true if (value !== undefined) state.wsPort = Number(value) }, }, { names: ['--timeout'], apply: (state, value) => { if (value !== undefined) state.commandTimeoutMs = Number(value) }, }, { // `--sim` is the canonical target flag; `--session` / `--tab` are // documented aliases (CLAUDE.md, the rnx agent skills, and engine docs all // say `--session `). without these aliases an agent following the docs // would have `--session ef` silently ignored and get the *currently // pinned* sim's tree instead — wrong-app data with no error. names: ['--sim', '--session', '--tab'], apply: (state, value) => { if (value === undefined) return state.simId = value.trim() || undefined state.simIdSource = 'flag' }, }, ] export const BRIDGE_VALUE_FLAGS: readonly string[] = BRIDGE_VALUE_FLAGS_TABLE.flatMap( (flag) => [...flag.names], ) export function parseBridgeCliArgs( args: string[], opts: ParseBridgeCliArgsOptions = {}, ): ParsedBridgeCliArgs { const flagIndices = new Set() const state: BridgeFlagState = { wsPort: opts.port ?? resolveDefaultBridgePort(), explicitPort: opts.port !== undefined, commandTimeoutMs: opts.commandTimeoutMs ?? 15000, simId: undefined, simIdSource: 'none', } const stripBooleanFlags = new Set(opts.stripBooleanFlags ?? []) const stripValueFlags = new Set(opts.stripValueFlags ?? []) for (let i = 0; i < args.length; i++) { const arg = args[i] // both spellings for every flag in the table: `--flag value` and // `--flag=value`. const valueFlag = BRIDGE_VALUE_FLAGS_TABLE.find((flag) => flag.names.some((name) => arg === name || arg.startsWith(`${name}=`)), ) if (valueFlag) { flagIndices.add(i) const inline = valueFlag.names.find((name) => arg.startsWith(`${name}=`)) if (inline) { valueFlag.apply(state, arg.slice(inline.length + 1)) continue } const value = i + 1 < args.length ? args[i + 1] : undefined if (value !== undefined) flagIndices.add(i + 1) valueFlag.apply(state, value) i++ continue } if (stripBooleanFlags.has(arg)) { flagIndices.add(i) continue } if (stripValueFlags.has(arg)) { flagIndices.add(i) if (i + 1 < args.length) { flagIndices.add(i + 1) } i++ } } if (!state.simId) { const savedSim = readCurrentSim() if (savedSim) { state.simId = savedSim.simId.trim() || undefined state.simIdSource = 'saved' } } return { positional: args.filter((_, i) => !flagIndices.has(i)), wsPort: state.wsPort, explicitPort: state.explicitPort, simId: state.simId, simIdSource: state.simIdSource, commandTimeoutMs: state.commandTimeoutMs, } } export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBridge { let nextId = 1 const commandTimeoutMs = opts.commandTimeoutMs ?? 15000 const pending = new Map< number, { resolve: (v: any) => void; reject: (e: Error) => void } >() const ws = new WebSocket(bridgeWsUrl(wsPort)) // always key the lease on the stable agent/terminal identity, never on the // target simId. keying off `sim:` conflated "which sim" with "who is // driving it": a single agent's identity then flipped between its stable key // (untargeted commands) and `sim:` (targeted commands), so the agent // could take a lease under one key and get refused under the other — locked // out of a sim it owns. it also made two different agents driving the same // sim collide into one identity and trample each other's lease. getCliIdentity() // is already stable per agent run (CLAUDE_CODE_SESSION_ID / per-terminal) and // distinct between agents — exactly what the lease wants. const identity = getCliIdentity() const ready = new Promise((resolve, reject) => { ws.on('open', () => { // identify ourselves so the server keys leases on the agent run, // not the ephemeral ws connection (CLI invocations are short-lived). // when identity is unstable the server falls back to a per-socket key, // which is still safer than letting multiple agents share a sim. try { ws.send( JSON.stringify({ type: 'bridge:hello', id: 0, cliIdentityKey: identity.key, cliIdentitySource: identity.source, cliLabel: opts.cliLabel, }), ) } catch {} resolve() }) ws.on('error', (err) => { // err.message is often empty (ECONNREFUSED surfaces with no message on // some node builds) — don't emit a dangling "...7668: " with no cause. const cause = err.message ? `: ${err.message}` : '' reject(new Error(`could not connect to ${bridgeWsUrl(wsPort)}${cause}`)) }) }) // a bridge closed before its handshake finished rejects `ready` with nothing // awaiting it (every send awaits ready and still sees the rejection); mark it // handled so that path never surfaces as an unhandled rejection. ready.catch(() => {}) let warnedAboutContention = false let noticedTarget = false // the browser host and connection behind each target, learned while that // sim is still connected. a reload changes the connection, and sometimes the // sim id, but not the host. const lineageBySimId = new Map() const lineageLookupBySimId = new Map>() // a reload retires the socket but keeps the browser host. the replacement // can receive a new sim id or restore the prior one from session storage. // the unloading page's socket stays listed as open until its close lands, so // only a connection newer than the known one is a replacement. the restored // id is definitive; a new id must be the only newer same-host match. function pickReloadedTarget(sims: unknown, retiredSimId: string): string | null { const lineage = lineageBySimId.get(retiredSimId) if (!lineage) return null const matches = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter( (sim) => sim.readyState === 'open' && sim.meta?.sootsimHostPid === lineage.hostPid && sim.connectedAt > lineage.connectedAt, ) const successor = matches.find((sim) => sim.id === retiredSimId) ?? (matches.length === 1 ? matches[0] : null) if (!successor) return null lineageBySimId.set(successor.id, { hostPid: lineage.hostPid, connectedAt: successor.connectedAt, }) return successor.id } // print a one-line notice on first sim-scoped command so agents/users see // which sim the command actually hit. suppressed for bridge:* // control commands (list/open/focus/close/claim) that don't target a // single sim, and for the hidden SOOTSIM_QUIET_TARGET_NOTICE escape hatch // used by scripts that prefer clean stdout. function maybeNoticeTarget(cmdType: string, simId: string | undefined) { if (noticedTarget) return if (process.env.SOOTSIM_QUIET_TARGET_NOTICE === '1') return if (cmdType.startsWith('bridge:') || cmdType === 'focus' || cmdType === 'close') return noticedTarget = true const label = simId ?? 'primary' const source = describeSimIdSource(simId ? opts.simIdSource : 'none') process.stderr.write(` → ${label} (${source})\n`) } ws.on('message', (data) => { let msg: any try { msg = JSON.parse(data.toString()) } catch { return } if (msg.id === 0) return // bridge:hello ack const p = pending.get(msg.id) if (!p) return pending.delete(msg.id) // warn once if another CLI identity is also targeting this sim. this is // NOT a bridge-throughput / congestion warning — the bridge is a thin ws // relay and handles many messages/sec. it's a *semantic* warning: two // agents driving the same screen will collide on taps/scroll/keyboard // state. writes already serialize via the cli lease; reads pass through. if (msg._otherCliCount > 0 && !warnedAboutContention) { warnedAboutContention = true process.stderr.write( `\n ⚠ ${msg._otherCliCount} other CLI identity/identities are driving this sim\n` + ' taps, scrolls, and keyboard input from multiple agents will collide on\n' + ' the same screen state (this is not a bridge throughput limit).\n' + ' use `rnx open --new` for an isolated sim per agent.\n\n', ) } if (msg.error) { if (msg._locked) { p.reject(new BridgeSimLockedError(msg.error, msg._locked as BridgeLockInfo)) } else { p.reject(new Error(msg.error)) } } else { p.resolve(msg.result) } }) // when the bridge host or target sim disconnects, reject all pending commands // so the CLI gets a clear error instead of silently timing out ws.on('close', (code, reason) => { const msg = reason?.toString() || 'connection closed' for (const [id, p] of pending) { pending.delete(id) p.reject(new Error(`sim disconnected: ${msg} (code ${code})`)) } }) return { plane: 'local', async send(cmd, callOpts) { // NOTE: `opts` (the createBridge closure arg) carries simId; do not // shadow it with the per-call arg. const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs const sendOnce = async ( defaultSimId?: string, cmdOverride?: Record, ) => { await ready const id = nextId++ return new Promise((resolve, reject) => { const timeout = setTimeout(() => { pending.delete(id) reject( new Error( `command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`, ), ) }, effectiveTimeoutMs) pending.set(id, { resolve: (v) => { clearTimeout(timeout) resolve(v) }, reject: (e) => { clearTimeout(timeout) reject(e) }, }) const payload = { ...(cmdOverride ?? cmd), id } as Record if (payload.simId === undefined && defaultSimId) { payload.simId = defaultSimId } maybeNoticeTarget(payload.type ?? '', payload.simId) ws.send(JSON.stringify(payload)) }) } // re-resolve the saved pin per send for bridges that were not // explicitly `--sim`-targeted. a poll loop (`wait ready`) started // before a concurrent `rnx open` finished connecting would // otherwise stay on the primary fallback for its whole budget, probing // an unrelated (often empty) sim while the intended one connects and // pins itself mid-wait. falling back to the startup pin keeps the // fail-closed saved-sim-gone path below when the pin file is cleared. const sentSimId = opts.simIdSource === 'flag' ? opts.simId : (readCurrentSimId() ?? opts.simId) const targetedSimId = typeof cmd.simId === 'string' && cmd.simId ? cmd.simId : sentSimId try { // a command can itself reload the page and retire its sim id. capture // the browser-host lineage before dispatch, while the target still // exists, so the caller can follow the replacement without guessing. if ( targetedSimId && cmd.type !== 'bridge:list-sims' && !lineageBySimId.has(targetedSimId) ) { let lookup = lineageLookupBySimId.get(targetedSimId) if (!lookup) { lookup = (async () => { try { const sims = await sendOnce(undefined, { type: 'bridge:list-sims' }) if (!Array.isArray(sims)) return const targeted = (sims as BridgeSimInfo[]).find( (sim) => sim.id === targetedSimId, ) const hostPid = targeted?.meta?.sootsimHostPid if (targeted && typeof hostPid === 'number') { lineageBySimId.set(targetedSimId, { hostPid, connectedAt: targeted.connectedAt, }) } } catch { // lineage enables fail-closed reload following, but a failed // observation must not replace the caller's command failure. } finally { lineageLookupBySimId.delete(targetedSimId) } })() lineageLookupBySimId.set(targetedSimId, lookup) } await lookup } return await sendOnce(sentSimId) } catch (error) { const message = error instanceof Error ? error.message : String(error) const missingSimMessage = `no sim connected with id ${sentSimId}` if ( opts.simIdSource !== 'flag' && sentSimId && cmd.simId === undefined && (message === missingSimMessage || message.startsWith(`${missingSimMessage};`)) ) { // a reload retires the sim id but keeps the browser host, so the // successor with the same host pid IS the same page lineage — not a // guess. adopt only that, and only when it is unambiguous. const successor = pickReloadedTarget( await sendOnce(undefined, { type: 'bridge:list-sims' }), sentSimId, ) if (successor) { saveCurrentSimId(successor) process.stderr.write( ` note: sim ${sentSimId} reloaded and reconnected as ${successor}; following it\n`, ) return sendOnce(successor) } clearCurrentSimId() // the saved sim is gone and nothing proves what replaced it. fail // closed instead of driving whichever sim is primary; that is how // agents end up recording the wrong app. throw new Error( `saved sim ${sentSimId} is gone${message.slice(missingSimMessage.length)}; ` + 'run `rnx list` and ' + `\`rnx use \`, or open a fresh sim`, ) } throw error } }, async listSims() { const sims = await this.send({ type: 'bridge:list-sims' }) return Array.isArray(sims) ? (sims as BridgeSimInfo[]) : [] }, // a caller holding its own pinned sim id (a flow runner sets simId on every // command, so it never reaches the pin-following path in send) asks here // after a reload retires its socket. the page can reconnect under a new id // or restore the old id; null means no unique same-host target exists. async resolveReloadedSim(retiredSimId: string) { return pickReloadedTarget(await this.listSims(), retiredSimId) }, async focusSim(simId?: string) { return this.send({ type: 'focus', simId }) }, async closeSim(simId?: string) { return this.send({ type: 'close', simId }) }, async claim(simId?: string, claimOpts: { force?: boolean } = {}) { const result = await this.send({ type: 'bridge:claim', simId, force: claimOpts.force === true, }) return result as BridgeClaimResult }, close() { // send an explicit goodbye so the server deregisters this socket // synchronously instead of waiting on the tcp close event, which // can lag the next cli invocation and produce phantom-peer warnings. try { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'bridge:bye', id: 0 })) } } catch {} ws.close() // clear the grace timer on a real close, or a one-shot `rnx` command is // held on the event loop for the full 250ms after its work is done. that // delay lands directly in in-box prompt latency, since the shell spawns // rnx and waits on child close. const terminateGrace = setTimeout(() => { if (ws.readyState !== WebSocket.CLOSED) { ws.terminate() } }, 250) ws.once('close', () => clearTimeout(terminateGrace)) }, } } /** * how a command reaches a simulator. left out, it resolves the way the host * CLI always has: the cloud box route when a box session is set, otherwise the * local ws daemon. a box shell passes its own bridge so the same command runs * in the calling process against the box's own simulator. */ export interface BridgeTransportOption { createBridge?: (parsed: ParsedBridgeCliArgs) => WsBridge } export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge { const cloudBridge = createCloudBridgeForParsed(parsed) if (cloudBridge) return cloudBridge return createBridge(parsed.wsPort, { commandTimeoutMs: parsed.commandTimeoutMs, simId: parsed.simId, simIdSource: parsed.simIdSource, }) } interface SimHealthProbe { hidden?: boolean visibilityState?: string hasFocus?: boolean | null simId?: string | null url?: string } function describeVisibleSimCandidate(sims: BridgeSimInfo[], currentId?: string | null) { const open = sims.filter((sim) => sim.readyState === 'open' && sim.id !== currentId) const candidate = open.find((sim) => sim.userFocused) ?? open.find((sim) => sim.userVisible === true && sim.isPrimary) ?? open.find((sim) => sim.userVisible === true) ?? open.find((sim) => sim.isPrimary) if (!candidate) return null const tags = [ candidate.isPrimary ? 'primary' : null, candidate.userVisible === false ? 'hidden' : candidate.userVisible === true ? 'visible' : null, candidate.userFocused ? 'focused' : null, ].filter(Boolean) return `${candidate.id}${tags.length ? ` [${tags.join(', ')}]` : ''}` } /** * Check if the target sim is healthy for interaction. Warns on stderr if: * - document.hidden is true (animations won't run, coordinates may be wrong) * Returns the health state so callers can decide whether to proceed. */ export async function checkSimHealth( bridge: WsBridge, ): Promise<{ hidden: boolean; warned: boolean; simId?: string | null }> { // a CPU simulator has no document to be hidden, and the probe below is an // `evaluate` the cloud runtime refuses by name. if (bridge.plane === 'cloud') return { hidden: false, warned: false } try { const probe = (await bridge.send({ type: 'evaluate', code: `(() => ({ hidden: document.hidden, visibilityState: document.visibilityState, hasFocus: typeof document.hasFocus === 'function' ? document.hasFocus() : null, simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null, url: location.href }))()`, })) as SimHealthProbe | boolean | null const health = typeof probe === 'object' && probe !== null ? probe : { hidden: probe === true } const hidden = health.hidden === true if (hidden === true) { let candidate: string | null = null try { candidate = describeVisibleSimCandidate(await bridge.listSims(), health.simId) } catch {} process.stderr.write( `\n ⚠ target sim${health.simId ? ` ${health.simId}` : ''} is hidden (document.hidden = true)\n` + (health.visibilityState ? ` visibility: ${health.visibilityState}\n` : '') + ' animations and rAF callbacks are throttled — coordinates may be wrong\n' + ' and launch/transition animations will not complete.\n' + (candidate ? ` another open sim looks usable: ${candidate}; target it with --sim.\n` : ' run `rnx list` to find the visible sim and target it with --sim.\n') + ' page-level window.focus() cannot unhide a browser tab.\n\n', ) return { hidden: true, warned: true, simId: health.simId } } return { hidden: false, warned: false, simId: health.simId } } catch { return { hidden: false, warned: false } } } /** * Convenience: run arbitrary JavaScript in the rnx page and return the * result. Hides the `type: 'evaluate'` message shape. Read-only by default — * pass `{ acquireLock: true }` when the eval mutates user-facing state so * the server takes/refreshes the cli lease and blocks concurrent agents. */ export async function evalInBridge( bridge: WsBridge, code: string, opts: { acquireLock?: boolean } = {}, ): Promise { const payload: WsBridgeCommand = { type: 'evaluate', code } if (opts.acquireLock) payload.acquireLock = true return bridge.send(payload) as Promise } /** * Invoke a function on `window.SootSim.bridges.*` over the bridge. Read-only * by default for the same reason as evalInBridge. */ export async function callInBridge( bridge: WsBridge, path: string, ...args: unknown[] ): Promise { return bridge.send({ type: 'call', path, args }) as Promise } /** Write variant — takes/refreshes the cli lease on the target sim. */ export async function callInBridgeWrite( bridge: WsBridge, path: string, ...args: unknown[] ): Promise { return bridge.send({ type: 'call', path, args, acquireLock: true }) as Promise }